# 8 Solidity Design Patterns Every Developer Should Know
Design patterns are battle-tested solutions to recurring problems. In Solidity, they prevent bugs, save gas, and make code maintainable.
Unlike web development, smart contract mistakes are permanent. A single reentrancy bug can drain millions. These 8 patterns will make you a safer, more professional developer.
1. Checks-Effects-Interactions (CEI)
Problem: Reentrancy attacks—external calls triggering unexpected re-entry.
Solution: Follow this order in every function:
require)Vulnerable Code:
function withdraw(uint amount) public {
require(balances[msg.sender] >= amount);
// INTERACTION before EFFECT (vulnerable!)
(bool success,) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] -= amount; // Updated AFTER external call
}
Attack:
The attacker's fallback function calls withdraw again before balances is updated, draining the contract.
Secure Code (CEI):
function withdraw(uint amount) public {
// 1. CHECKS
require(balances[msg.sender] >= amount, "Insufficient balance");
// 2. EFFECTS
balances[msg.sender] -= amount;
// 3. INTERACTIONS
(bool success,) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
Now, even if the attacker re-enters, their balance is already zero.
When to use: Always, especially when making external calls.
---
2. Pull Over Push (Withdrawal Pattern)
Problem: Pushing payments to multiple addresses can fail if one reverts, blocking everyone.
Solution: Let users pull their funds instead of pushing.
Bad (Push):
function distributeRewards(address[] memory recipients) public {
for (uint i = 0; i < recipients.length; i++) {
// If one transfer fails, entire function reverts
(bool success,) = recipients[i].call{value: 1 ether}("");
require(success);
}
}
Good (Pull):
mapping(address => uint) public pendingWithdrawals;
function distributeRewards(address[] memory recipients) public {
for (uint i = 0; i < recipients.length; i++) {
pendingWithdrawals[recipients[i]] += 1 ether;
}
}
function withdraw() public {
uint amount = pendingWithdrawals[msg.sender];
require(amount > 0, "No funds");
pendingWithdrawals[msg.sender] = 0; // CEI pattern
(bool success,) = msg.sender.call{value: amount}("");
require(success);
}
Benefits:
- One user can't block others
- Gas-efficient (no loops in critical path)
- User controls when to withdraw
When to use: Airdrops, dividends, reward distribution.
---
3. Guard Check (Modifier Pattern)
Problem: Repeated validation logic clutters functions.
Solution: Extract checks into reusable modifier functions.
Without Modifiers:
function adminFunction() public {
require(msg.sender == owner, "Not owner");
require(!paused, "Contract paused");
// ... logic
}
function anotherAdminFunction() public {
require(msg.sender == owner, "Not owner");
require(!paused, "Contract paused");
// ... logic
}
With Modifiers:
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
modifier whenNotPaused() {
require(!paused, "Contract paused");
_;
}
function adminFunction() public onlyOwner whenNotPaused {
// Clean, readable logic
}
Common Modifiers:
modifier nonReentrant() {
require(!locked, "Reentrant call");
locked = true;
_;
locked = false;
}
modifier validAddress(address addr) {
require(addr != address(0), "Zero address");
_;
}
modifier withinLimit(uint amount) {
require(amount <= MAX_AMOUNT, "Exceeds limit");
_;
}
When to use: Any repeated validation logic.
---
4. Factory Pattern
Problem: Deploying multiple instances of the same contract.
Solution: A factory contract that creates and tracks child contracts.
// Child contract
contract Token {
string public name;
address public owner;
constructor(string memory _name, address _owner) {
name = _name;
owner = _owner;
}
}
// Factory contract
contract TokenFactory {
Token[] public tokens;
mapping(address => Token[]) public userTokens;
event TokenCreated(address indexed owner, address tokenAddress);
function createToken(string memory name) public returns (address) {
Token newToken = new Token(name, msg.sender);
tokens.push(newToken);
userTokens[msg.sender].push(newToken);
emit TokenCreated(msg.sender, address(newToken));
return address(newToken);
}
function getTokenCount() public view returns (uint) {
return tokens.length;
}
function getUserTokens(address user) public view returns (Token[] memory) {
return userTokens[user];
}
}
Benefits:
- Centralized registry of all instances
- User-friendly deployment (one transaction)
- Track ownership/metrics
Advanced: Minimal Proxy (EIP-1167)
For gas-efficient clones:
import "@openzeppelin/contracts/proxy/Clones.sol";
contract MinimalProxyFactory {
address public implementation;
constructor(address _implementation) {
implementation = _implementation;
}
function createClone() public returns (address) {
// Costs ~10x less gas than 'new'
return Clones.clone(implementation);
}
}
When to use: Token factories, NFT collections, DAO creation.
---
5. Proxy Pattern (Upgradeable Contracts)
Problem: Smart contracts are immutable—you can't fix bugs or add features.
Solution: Separate logic (upgradeable) from storage (permanent).
Transparent Proxy (OpenZeppelin):
// Implementation contract (logic)
contract BoxV1 {
uint256 public value;
function store(uint256 newValue) public {
value = newValue;
}
}
// Upgraded version
contract BoxV2 {
uint256 public value;
function store(uint256 newValue) public {
value = newValue;
}
function increment() public {
value += 1;
}
}
Deployment with Foundry:
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
contract DeployProxy is Script {
function run() external {
BoxV1 implementation = new BoxV1();
TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(
address(implementation),
msg.sender, // admin
"" // no initialization data
);
// Use proxy as BoxV1
BoxV1(address(proxy)).store(42);
}
}
Upgrading:
function upgrade() external {
BoxV2 newImplementation = new BoxV2();
ProxyAdmin(proxyAdmin).upgrade(proxy, address(newImplementation));
}
Critical Rules:
When to use: High-value contracts (DAOs, treasuries), evolving protocols.
---
6. Access Control (Role-Based)
Problem: Simple onlyOwner doesn't scale. You need roles (admin, minter, burner).
Solution: OpenZeppelin's AccessControl pattern.
import "@openzeppelin/contracts/access/AccessControl.sol";
contract RoleBasedToken is AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
mapping(address => uint) public balances;
constructor() {
// Deployer is default admin
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(MINTER_ROLE, msg.sender);
}
function mint(address to, uint amount) public onlyRole(MINTER_ROLE) {
balances[to] += amount;
}
function burn(address from, uint amount) public onlyRole(BURNER_ROLE) {
balances[from] -= amount;
}
// Admin can grant/revoke roles
function addMinter(address account) public onlyRole(DEFAULT_ADMIN_ROLE) {
grantRole(MINTER_ROLE, account);
}
}
Hierarchical Roles:
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant MODERATOR_ROLE = keccak256("MODERATOR_ROLE");
constructor() {
_setRoleAdmin(MODERATOR_ROLE, ADMIN_ROLE); // Admins manage moderators
}
When to use: DAOs, multi-admin systems, DeFi protocols.
---
7. Oracle Pattern (Trusted Data Source)
Problem: Smart contracts can't access off-chain data (price feeds, weather, sports scores).
Solution: Oracle pattern with trusted data providers.
Simple Oracle (Centralized):
contract PriceOracle {
address public oracle;
mapping(string => uint) public prices; // symbol => price in USD (8 decimals)
event PriceUpdated(string symbol, uint price, uint timestamp);
modifier onlyOracle() {
require(msg.sender == oracle, "Not oracle");
_;
}
constructor(address _oracle) {
oracle = _oracle;
}
function updatePrice(string memory symbol, uint price) public onlyOracle {
prices[symbol] = price;
emit PriceUpdated(symbol, price, block.timestamp);
}
function getPrice(string memory symbol) public view returns (uint) {
uint price = prices[symbol];
require(price > 0, "Price not set");
return price;
}
}
// Consumer contract
contract LendingProtocol {
PriceOracle oracle;
function calculateCollateral(uint ethAmount) public view returns (uint usdValue) {
uint ethPrice = oracle.getPrice("ETH"); // e.g., 2000_00000000 ($2000)
usdValue = (ethAmount * ethPrice) / 1e18;
}
}
Production: Use Chainlink
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
contract ChainlinkConsumer {
AggregatorV3Interface internal priceFeed;
constructor() {
// ETH/USD on Ethereum mainnet
priceFeed = AggregatorV3Interface(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419);
}
function getLatestPrice() public view returns (int) {
(, int price,,,) = priceFeed.latestRoundData();
return price; // 8 decimals
}
}
When to use: DeFi (price feeds), gaming (randomness), insurance (real-world events).
---
8. Emergency Stop (Circuit Breaker)
Problem: A critical bug is discovered—you need to pause the contract.
Solution: Pausable pattern.
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract EmergencyStop is Pausable, Ownable {
mapping(address => uint) public balances;
constructor() Ownable(msg.sender) {}
function deposit() public payable whenNotPaused {
balances[msg.sender] += msg.value;
}
function withdraw(uint amount) public whenNotPaused {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
// Emergency functions (admin only)
function pause() public onlyOwner {
_pause();
}
function unpause() public onlyOwner {
_unpause();
}
}
Advanced: Time-Locked Pause
uint public pausedUntil;
function emergencyPause(uint duration) public onlyOwner {
pausedUntil = block.timestamp + duration;
}
modifier whenNotPaused() {
require(block.timestamp > pausedUntil, "Contract paused");
_;
}
When to use: High-value protocols, public launches, uncertain security.
---
Combining Patterns
Real-world contracts use multiple patterns:
contract SecureVault is Ownable, Pausable, ReentrancyGuard {
mapping(address => uint) public balances;
// Guard Check + Emergency Stop
function deposit() public payable whenNotPaused {
balances[msg.sender] += msg.value;
}
// CEI + Pull Pattern + Reentrancy Guard
function withdraw(uint amount) public whenNotPaused nonReentrant {
// CHECKS
require(balances[msg.sender] >= amount, "Insufficient balance");
// EFFECTS
balances[msg.sender] -= amount;
// INTERACTIONS
(bool success,) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
// Access Control
function pause() public onlyOwner {
_pause();
}
}
---
Pattern Selection Guide
| Use Case | Patterns |
|----------|----------|
| Token contract | CEI, Guard Check, Access Control |
| NFT marketplace | CEI, Pull Pattern, Emergency Stop |
| DAO | Factory, Proxy, Access Control |
| DeFi protocol | CEI, Oracle, Emergency Stop, Pull Pattern |
| Upgradeable system | Proxy, Access Control |
---
Anti-Patterns to Avoid
1. Tx.origin for Authentication
// VULNERABLE
require(tx.origin == owner);
// SECURE
require(msg.sender == owner);
2. Floating Pragma
// RISKY
pragma solidity ^0.8.0;
// SAFE
pragma solidity 0.8.20;
3. Block Variables for Randomness
// PREDICTABLE (miners can manipulate)
uint random = uint(keccak256(abi.encodePacked(block.timestamp, block.difficulty)));
// SECURE
// Use Chainlink VRF for true randomness
---
Conclusion
These 8 patterns are the foundation of professional Solidity development:
Master these, and you'll write code that's:
- Secure (resistant to attacks)
- Efficient (gas-optimized)
- Maintainable (clear, modular)
- Professional (follows industry standards)
Next Steps:
- Study audited code on Etherscan
- Practice implementing each pattern
Build better. Build safer. 🛡️