Skip to content

Smart Contracts: Design Patterns and Best Practices

DodaTech Updated 2026-06-22 7 min read

In this tutorial, you'll learn smart contract design patterns including ownership, access control, emergency stop, and upgradeability for production-ready decentralized applications. Why it matters: repeating battle-tested design patterns prevents costly vulnerabilities and reduces audit risk, as most DeFi exploits exploit missing or misapplied patterns. By the end, you'll implement professional-grade contracts using standard patterns.

Smart contract design patterns are reusable solutions to common blockchain development problems, addressing unique challenges like immutability, gas costs, and decentralized access control.

Ownership and Access Control

The most fundamental pattern restricts sensitive functions to the contract owner. OpenZeppelin's Ownable is the industry standard.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

contract AccessControlled is Ownable {
    // Owner can do anything
    function ownerAction() external onlyOwner {
        // Owner-restricted logic
    }
}

contract RoleBased is AccessControl {
    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    
    constructor() {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(MINTER_ROLE, msg.sender);
    }
    
    function mint(address _to, uint256 _amount) external onlyRole(MINTER_ROLE) {
        // Only minters can call this
    }
}

Expected behavior: ownerAction() only succeeds when called by the contract deployer. mint() requires the caller to have the MINTER_ROLE. AccessControl enables granular multi-user permissions.

flowchart TD
  A[Contract Functions] --> B{Access Control}
  B -->|Ownable| C[Single Owner]
  B -->|AccessControl| D[Multiple Roles]
  C --> E[onlyOwner Modifier]
  D --> F[DEFAULT_ADMIN_ROLE]
  D --> G[MINTER_ROLE]
  D --> H[PAUSER_ROLE]
  E --> I[msg.sender == owner]
  F --> J[Can grant/revoke roles]
  G --> K[Can mint tokens]
  H --> L[Can pause contract]

Pull-Over-Push Pattern

Instead of sending ETH directly to users (push), let them withdraw their funds (pull). This prevents denial-of-service attacks where a malicious recipient reverts on ETH reception.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract PullOverPush {
    mapping(address => uint256) public pendingWithdrawals;
    
    event Withdrawal(address indexed user, uint256 amount);
    
    // Push pattern (vulnerable)
    function pushPayment(address _recipient) external payable {
        // BAD: if _recipient is a contract with a malicious receive(),
        // this will revert and payment fails
        payable(_recipient).transfer(msg.value);
    }
    
    // Pull pattern (safe)
    function pullPayment(address _user) external payable {
        pendingWithdrawals[_user] += msg.value;
    }
    
    function withdraw() external {
        uint256 amount = pendingWithdrawals[msg.sender];
        require(amount > 0, "Nothing to withdraw");
        
        // Reset balance before sending (prevents reentrancy)
        pendingWithdrawals[msg.sender] = 0;
        
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
        
        emit Withdrawal(msg.sender, amount);
    }
}

Expected behavior: pushPayment can fail if the recipient rejects ETH. pullPayment credits the user's pending balance. The user calls withdraw() at their convenience. The pattern prevents gas griefing and reentrancy.

Emergency Stop Pattern

Also called the circuit breaker, this pattern allows pausing critical contract functions during emergencies or detected vulnerabilities.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@openzeppelin/contracts/access/Ownable.sol";

contract EmergencyStop is Ownable {
    bool public paused;
    
    modifier whenNotPaused() {
        require(!paused, "Contract is paused");
        _;
    }
    
    modifier whenPaused() {
        require(paused, "Contract is not paused");
        _;
    }
    
    function pause() external onlyOwner {
        paused = true;
    }
    
    function unpause() external onlyOwner whenPaused {
        paused = false;
    }
    
    // Critical functions use whenNotPaused
    function withdraw(uint256 _amount) external whenNotPaused {
        // Normal operation
    }
    
    function emergencyWithdraw() external {
        // Allow users to withdraw their funds even when paused
    }
}

Expected behavior: pause() freezes all whenNotPaused functions. The owner can unpause() to resume operations. An emergencyWithdraw function (not paused) lets users recover funds even during emergencies.

Upgradeable Contracts

Smart contracts are immutable by default. The proxy pattern enables upgrades by separating logic and storage.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

// Storage contract (not upgradeable)
contract Storage {
    uint256 public value;
}

// Proxy contract (delegates calls to implementation)
contract Proxy {
    address public implementation;
    address public admin;
    
    constructor(address _implementation) {
        admin = msg.sender;
        implementation = _implementation;
    }
    
    function upgrade(address _newImplementation) external {
        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()) }
        }
    }
}

// Version 1 logic
contract LogicV1 {
    uint256 public value;
    
    function setValue(uint256 _value) external {
        value = _value;
    }
}

// Version 2 logic (added getValue)
contract LogicV2 {
    uint256 public value;
    
    function setValue(uint256 _value) external {
        value = _value;
    }
    
    function getValue() external view returns (uint256) {
        return value;
    }
}

Expected behavior: Users interact with the Proxy contract. The Proxy delegates calls to LogicV1. When LogicV2 is deployed, the admin calls upgrade(LegicV2Addr) and all existing state is preserved. The storage layout must remain compatible between versions.

Checks-Effects-Interactions Pattern

This pattern prevents reentrancy attacks by ordering operations: validate inputs (checks), update state (effects), then make external calls (interactions).

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract ChecksEffectsInteractions {
    mapping(address => uint256) public balances;
    
    // VULNERABLE: state not updated before external call
    function vulnerableWithdraw(uint256 _amount) external {
        require(balances[msg.sender] >= _amount);
        
        (bool sent, ) = msg.sender.call{value: _amount}("");  // Interaction FIRST
        require(sent, "Failed");
        
        balances[msg.sender] -= _amount;  // State update AFTER
    }
    
    // SAFE: follows checks-effects-interactions
    function safeWithdraw(uint256 _amount) external {
        // Checks
        require(balances[msg.sender] >= _amount, "Insufficient balance");
        require(_amount > 0, "Zero amount");
        
        // Effects
        balances[msg.sender] -= _amount;
        
        // Interactions
        (bool sent, ) = msg.sender.call{value: _amount}("");
        require(sent, "Transfer failed");
    }
}

Expected behavior: vulnerableWithdraw allows reentrancy because the balance is updated after the ETH transfer. safeWithdraw updates the balance before the external call, so reentrancy cannot drain the contract.

Common Errors and Misunderstandings

1. Using tx.origin for Authentication

tx.origin is the original sender of the transaction. A malicious contract can impersonate the user. Always use msg.sender.

2. Skipping Reentrancy Guards

Even with checks-effects-interactions, add reentrancy guards (ReentrancyGuard from OpenZeppelin) as defense-in-depth.

3. Incorrect Proxy Storage Layout

Upgradeable contracts must preserve storage layout between versions. Adding a new state variable before existing ones corrupts storage.

4. Not Handling ETH Rejection

Some contracts or wallets reject ETH. Always use the pull-over-push pattern or check call success.

5. Single Point of Failure

Using onlyOwner creates a central point of failure. Consider multi-sig or timelock for production contracts.

Practice Questions

  1. Why is pull-over-push safer than push? Push transfers can fail if the recipient reverts, breaking the entire transaction. Pull lets users withdraw on their terms and prevents denial-of-service attacks.

  2. What is the proxy pattern used for? Proxy patterns enable contract upgrades by separating logic (implementation contract) from storage (proxy). Users interact with the proxy, which delegates calls to the current implementation.

  3. How does checks-effects-interactions prevent reentrancy? By updating state before external calls, reentrant calls see the updated state and cannot double-withdraw. The attacker's reentry triggers the updated balance check.

  4. What is the difference between Ownable and AccessControl? Ownable provides a single owner role. AccessControl supports multiple roles with granular permissions, enabling multi-admin setups and role-based access.

  5. When should you use an emergency stop pattern? During detected vulnerabilities, major bugs, or oracle failures. Pause critical functions while keeping withdrawal and admin functions available for recovery.

Challenge

Build a timelock-controlled treasury contract that uses the proxy pattern for upgrades, Ownable for admin functions, AccessControl for operator roles, pull-over-push for payments, and an emergency pause. Write comprehensive tests in Hardhat.

Real-World Task

Design a decentralized escrow contract that holds funds for peer-to-peer trading. Implement access control for arbitrators, emergency pause for dispute resolution, pull-over-push for fee collection, and checks-effects-interactions for all payment functions. Deploy with Hardhat and verify on Etherscan.

Frequently Asked Questions

What is the most important smart contract design pattern?

The checks-effects-interactions pattern is the most critical. It prevents reentrancy attacks, which have caused billions in losses (The DAO hack, $600M+). Always update state before making external calls.

Can you make a contract upgradeable without a proxy?

No. Contracts are immutable once deployed. The proxy pattern is the standard approach, where the proxy holds state and delegates logic calls to an upgradeable implementation contract. Alternative: use the diamond pattern (EIP-2535).

Should every contract have an emergency stop?

Yes, especially contracts holding user funds. An emergency pause lets you stop critical functions during exploits while maintaining withdrawal capabilities. The pause function should use a multi-sig or timelock for security.

Next Steps

Now that you understand design patterns, learn to set up a development environment with Hardhat and deploy production contracts. Then deep dive into Smart Contract Security for auditing and vulnerability prevention.

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro