Skip to content

Solidity Design Patterns — Production-Ready Smart Contract Patterns

DodaTech 7 min read

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

  1. Reentrancy in cross-contract calls — Sending ETH or calling external contracts before updating state leaves the contract vulnerable. Always apply Checks-Effects-Interactions.
  2. 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.
  3. Centralization in proxy admin — A single admin key controlling upgrades is a central point of failure. Use multisig wallets or timelocks for proxy administration.
  4. Inefficient storage access — Reading from storage repeatedly in loops costs excessive gas. Cache storage variables in memory.
  5. Missing access control on initialization — Uninitialized upgradeable contracts can be taken over by anyone calling initialize. Use OpenZeppelin's Initializable with proper modifiers.

Practice Questions

  1. 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.

  2. What is the difference between delegatecall and call? delegatecall executes the target contract's code in the caller's storage context — the target code reads and writes the caller's state. call executes code in the target's own storage context. The proxy pattern relies on delegatecall to share storage between the proxy and implementation.

  3. 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