Smart Contracts: Design Patterns and Best Practices
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
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.
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.
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.
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.
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
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