Solidity Design Patterns — Production-Ready Smart Contract Patterns
This tutorial covers essential Solidity Design Patterns — you will learn the Checks-Effects-Interactions pattern, the withdrawal pattern, proxy upgrades, access control, and gas optimization techniques used in production Ethereum contracts.
Why It Matters
Smart contracts manage billions of dollars in value. A single pattern mistake can lead to catastrophic loss — the DAO hack ($60M), Parity wallet freeze ($280M), and numerous other exploits all resulted from missing or incorrect Design Patterns. Production contracts must follow battle-tested patterns.
Real-World Use
OpenZeppelin's library implements the Ownable pattern used by 90% of Ethereum contracts. The proxy pattern powers every upgradeable contract from USDC to Uniswap. The withdrawal pattern protects users from reentrancy in exchanges like Binance's old ETH contract.
flowchart TD
DP[Solidity Design Patterns] --> CEI[Checks-Effects-Interactions]
DP --> WP[Withdrawal Pattern]
DP --> PP[Proxy Upgrade Pattern]
DP --> AC[Access Control]
DP --> GO[Gas Optimization]
CEI --> E1[Reentrancy Protection]
WP --> E2[Pull over Push Payments]
PP --> E3[Upgradeable Contracts]
AC --> E4[Role-Based Access]
GO --> E5[Lower Gas Costs]
style DP fill:#2196F3,color:#fff
style CEI fill:#4CAF50,color:#fff
style WP fill:#FF9800,color:#fff
style PP fill:#9C27B0,color:#fff
Checks-Effects-Interactions
This is the single most important security pattern. Always validate inputs first (checks), update state (effects), then call external contracts (interactions).
contract Auction {
address public highestBidder;
uint256 public highestBid;
// VULNERABLE: interaction before effects
function bidVulnerable() public payable {
require(msg.value > highestBid, "Bid too low");
// Interaction FIRST
if (highestBidder != address(0)) {
payable(highestBidder).transfer(highestBid);
}
// Effects AFTER - REENTRANCY HERE!
highestBidder = msg.sender;
highestBid = msg.value;
}
// SECURE: checks, effects, then interactions
function bidSecure() public payable {
// CHECK
require(msg.value > highestBid, "Bid too low");
// EFFECTS
address previousBidder = highestBidder;
uint256 previousBid = highestBid;
highestBidder = msg.sender;
highestBid = msg.value;
// INTERACTIONS
if (previousBidder != address(0)) {
payable(previousBidder).transfer(previousBid);
}
}
}
Expected behavior: the vulnerable version can be reentered — if the previous bidder is a malicious contract, its receive function calls bidVulnerable again before highestBidder is updated, draining the contract. The secure version updates state first, so reentrancy cannot manipulate balance checks.
Withdrawal Pattern
Instead of pushing payments (which can fail or be exploited), let users withdraw their own funds.
contract WithdrawalPattern {
mapping(address => uint256) private balances;
// PUSH: vulnerable to DoS and reentrancy
function payoutPush(address payable recipient) public {
uint256 amount = balances[recipient];
balances[recipient] = 0;
(bool sent, ) = recipient.call{value: amount}("");
require(sent, "Transfer failed");
}
// PULL: user withdraws their own funds
function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance to withdraw");
// Effects first
balances[msg.sender] = 0;
// Then interaction
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Withdrawal failed");
}
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function getBalance(address user) public view returns (uint256) {
return balances[user];
}
}
Expected behavior: users call deposit to add funds. When they want to withdraw, they call withdraw which clears their balance first, then sends ETH. If the transfer fails, their balance is already zero and they can retry. The push-based payoutPush would revert entirely if a single recipient's transfer fails.
Proxy Upgrade Pattern
Contracts are immutable — but you can deploy a proxy that delegates calls to an upgradeable implementation.
contract Proxy {
address public implementation;
address public admin;
constructor(address _implementation) {
implementation = _implementation;
admin = msg.sender;
}
function upgrade(address newImplementation) public {
require(msg.sender == admin, "Not admin");
implementation = newImplementation;
}
fallback() external payable {
address impl = implementation;
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
contract LogicV1 {
uint256 public value;
function setValue(uint256 _value) public {
value = _value;
}
}
contract LogicV2 {
uint256 public value;
uint256 public timestamp;
function setValue(uint256 _value) public {
value = _value;
timestamp = block.timestamp;
}
}
Expected behavior: deploy LogicV1, deploy Proxy pointing to LogicV1. Calling setValue(42) via the proxy stores 42 in the proxy's storage. Deploy LogicV2, call upgrade(LogicV2Address). Now calling setValue(100) via the proxy stores 100 AND records the timestamp, without losing the original value = 42 stored in the proxy's persistent storage.
Access Control Patterns
Granular role-based access control is essential for multi-user contract systems.
contract RoleBasedAccess {
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
mapping(bytes32 => mapping(address => bool)) public roles;
mapping(bytes32 => address[]) public roleMembers;
modifier onlyRole(bytes32 role) {
require(roles[role][msg.sender], "Missing role");
_;
}
function grantRole(bytes32 role, address account) public onlyRole(ADMIN_ROLE) {
require(!roles[role][account], "Already has role");
roles[role][account] = true;
roleMembers[role].push(account);
}
function revokeRole(bytes32 role, address account) public onlyRole(ADMIN_ROLE) {
require(roles[role][account], "Does not have role");
roles[role][account] = false;
}
function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
// minting logic
}
}
Expected behavior: the deployer is not automatically admin — an initial admin must be granted the ADMIN_ROLE through an initializer. Only admins can grant or revoke roles. Minters can call mint. Calling mint without the MINTER_ROLE reverts with "Missing role".
Gas Optimization Patterns
Gas costs directly impact user adoption. These patterns reduce Transaction costs.
contract GasOptimizer {
uint256[] public data;
uint256 public sum;
// EXPENSIVE: reads storage on every iteration
function sumExpensive() public {
uint256 total;
for (uint256 i = 0; i < data.length; i++) {
total += data[i]; // SLOAD each time
}
sum = total;
}
// OPTIMIZED: cache storage in memory
function sumOptimized() public {
uint256[] memory dataCache = data; // One SLOAD for the whole array
uint256 total;
uint256 len = dataCache.length; // One MLOAD for length
for (uint256 i = 0; i < len; i++) {
total += dataCache[i]; // MLOAD (cheap)
}
sum = total;
}
}
Expected gas savings: sumExpensive costs approximately 50,000 gas for an array of 100 elements. sumOptimized costs approximately 25,000 gas — a 50% reduction. For larger arrays, the savings are even more significant because SLOAD (2100 gas) is replaced by MLOAD (3 gas).
Common Errors
- Reentrancy in cross-contract calls — Sending ETH or calling external contracts before updating state leaves the contract vulnerable. Always apply Checks-Effects-Interactions.
- Storage collision in upgrades — Adding a variable before existing variables in an upgradeable contract shifts storage slots, corrupting state. Always append new variables at the end.
- Centralization in proxy admin — A single admin key controlling upgrades is a central point of failure. Use multisig wallets or timelocks for proxy administration.
- Inefficient storage access — Reading from storage repeatedly in loops costs excessive gas. Cache storage variables in memory.
- Missing access control on initialization — Uninitialized upgradeable contracts can be taken over by anyone calling
initialize. Use OpenZeppelin'sInitializablewith proper modifiers.
Practice Questions
Why must state updates happen before external calls? If an external call triggers a callback that re-enters the contract, the state should already reflect the intended changes. This prevents reentrancy attacks where the callback observes stale state and manipulates the contract's logic.
What is the difference between
delegatecallandcall?delegatecallexecutes the target contract's code in the caller's storage context — the target code reads and writes the caller's state.callexecutes code in the target's own storage context. The proxy pattern relies ondelegatecallto share storage between the proxy and implementation.Why use the withdrawal pattern instead of push payments? Push payments send ETH directly, which reverts the entire Transaction if the recipient rejects or fails to Process the transfer. The withdrawal pattern lets users pull their funds independently, isolating failures and preventing denial-of-service attacks against the contract.
Frequently Asked Questions
{{< faq question="What is the difference between Ownable and Role-Based Access Control?">}} Ownable defines a single owner who has special privileges — simple but inflexible. RBAC defines multiple roles with granular permissions — for example, a MINTER role can mint tokens, a PAUSER role can pause the contract, and an ADMIN role manages other roles. Use Ownable for simple contracts and RBAC for complex systems. OpenZeppelin provides both implementations. {{< /faq >}}
{{< faq question="Do I need the proxy pattern for every contract?">}} Only use the proxy pattern if you need upgradeability. Proxies add complexity, gas overhead (approximately 2,000 gas per call), and centralization risk through the upgrade key. Many successful contracts (Uniswap V3 core, MakerDAO) are immutable. Consider whether your contract logic truly needs to change after deployment — fix bugs through a new deployment and migrate state if necessary. {{< /faq >}}
{{< faq question="How do I test that my contract is not vulnerable to reentrancy?">}}
Use Hardhat's mainnet forking to simulate real DeFi interactions. Test with Foundry's fuzzing and invariant testing. OpenZeppelin's ReentrancyGuard provides a simple nonReentrant modifier. For thorough testing, write explicit exploit contracts that attempt to re-enter and verify they fail. Use Slither to automatically detect reentrancy vulnerabilities during CI.{{< /faq >}}
Next Steps
Smart Contract Security Audits — Learn how to audit contracts for pattern violations.
Testing Smart Contracts with Hardhat — Write tests that verify patterns are correctly implemented.
DeFi Explained — See these patterns applied in real DeFi protocols.
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro