Modular Smart-Contract DesignIntroductionA smart contract is more than a collection of functions deployed to Ethereum. In a production system, it is part of an economic and technical architecture involving users, other contracts, tokens, governance processes, oracles, wallets and off-chain services. Poorly structured contracts become difficult to test, upgrade, audit and operate. Modular design addresses this problem by dividing a system into components with clear responsibilities and controlled interactions. In Solidity, modularity is implemented through contracts, interfaces, abstract contracts, inheritance, composition and libraries. These mechanisms are supported by design principles such as separation of concerns, least privilege, checks-effects-interactions and reuse of audited components. Modularity does not automatically guarantee security; it makes a system easier to reason about when boundaries and assumptions are explicit. This chapter develops a practical design method for diploma-level Ethereum development. It uses a simple treasury application to illustrate how modules can be selected, connected and reviewed. Learning objectivesBy the end of this chapter, you should be able to: - distinguish contracts, interfaces, abstract contracts and libraries;
- compare inheritance with composition and select an appropriate approach;
- design modules with focused responsibilities and explicit dependencies;
- apply access control and the checks-effects-interactions pattern;
- identify risks created by external calls, delegatecall and upgradeability;
- use reusable components without treating reuse as a substitute for auditing;
- document and test the assumptions between interacting modules.
1. Modularity as a design disciplineA module is a unit of software with a defined responsibility, public boundary and internal implementation. A modular smart-contract system may contain: - a data or asset module, which stores balances or controls tokens;
- an authorization module, which determines who may perform privileged actions;
- a policy module, which defines business rules such as spending limits;
- an interaction module, which communicates with external contracts;
- a view module, which exposes derived information to users and interfaces.
The aim is not to create the greatest possible number of contracts. Excessive fragmentation increases deployment cost, transaction complexity and the number of interactions that must be understood. A useful module has a coherent purpose and a stable boundary. Two related design properties are important: - Cohesion measures how closely the responsibilities inside one module belong together. High cohesion is desirable.
- Coupling measures how strongly one module depends on another. Lower coupling generally improves maintainability, although some coupling is unavoidable in a functioning protocol.
A practical boundary should answer four questions: - What state does this module own?
- Which functions may external users or contracts call?
- What invariants must always remain true?
- What assumptions does the module make about its dependencies?
For example, a treasury may require the invariant that its recorded spending limit is not exceeded. The treasury should not also contain token pricing, user-interface formatting and unrelated governance logic. 2. Solidity mechanisms for modularityContractsA contract combines persistent state, executable functions, events, modifiers and error definitions. It may be deployed independently or used as a base contract. A contract normally owns state that is central to its responsibility. // SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Counter {
uint256 private _value;
function value() external view returns (uint256) {
return _value;
}
function increment() external {
_value += 1;
}
}The public interface is deliberately small. _value cannot be changed directly by another contract, and callers use the functions that enforce the intended behavior. InterfacesAn interface describes an externally callable contract boundary without providing implementation or storage. Interfaces are valuable when a contract needs to communicate with a dependency while remaining independent of that dependency’s internal code. interface IERC20Minimal {
function transfer(address to, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}An interface provides compile-time type information, but it does not prove that an address actually implements the promised behavior. A malicious or non-conforming contract can still be supplied at runtime. Therefore, addresses used as dependencies must be validated and governed appropriately. Interfaces also support substitutability: a treasury can work with any token that satisfies the required interface, subject to behavioral assumptions such as whether transfer returns a Boolean, charges fees or invokes callbacks. Abstract contractsAn abstract contract may contain state, implemented functions and functions marked virtual that derived contracts must implement. It expresses shared behavior while leaving some policy decisions to specialized contracts. abstract contract Pausable {
bool private _paused;
modifier whenNotPaused() {
require(!_paused, "paused");
_;
}
function _pause() internal {
_paused = true;
}
}Abstract contracts are useful for common access checks, emergency controls or accounting behavior. They should not be used merely to avoid writing a few lines of code. A base contract can introduce hidden state, initialization requirements and inherited permissions. LibrariesA library groups reusable functions. Library functions that do not modify storage are often stateless utilities; functions operating on a storage reference can provide controlled behavior for a data structure. Internal library functions are compiled into the calling contract, while some public library calls use deployed library code. library Percentage {
function of(uint256 amount, uint256 rateBps)
internal
pure
returns (uint256)
{
require(rateBps <= 10_000, "rate too high");
return amount * rateBps / 10_000;
}
}Here, 10,000 represents 100% in basis points, so rateBps = 250 represents 2.5%. Solidity 0.8.x checks arithmetic overflow by default, but multiplication can still revert if values exceed the type range. The function assumes that integer truncation from division is acceptable. A library is not a substitute for a contract with independent lifecycle or authority. It should encapsulate behavior, not conceal critical security assumptions. 3. Inheritance and compositionInheritanceInheritance creates an “is-a” relationship. A derived contract receives accessible state and behavior from a base contract and may override functions declared virtual. abstract contract Ownable {
address public owner;
constructor(address initialOwner) {
require(initialOwner != address(0), "zero owner");
owner = initialOwner;
}
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
}
contract ManagedCounter is Ownable {
uint256 public value;
constructor(address initialOwner) Ownable(initialOwner) {}
function setValue(uint256 newValue) external onlyOwner {
value = newValue;
}
}Inheritance can reduce duplication and establish a consistent policy. However, multiple inheritance creates a linearization order, and developers must understand which implementation is selected when functions overlap. Inherited storage layout is also significant in upgradeable contracts. A base contract may unintentionally expose a modifier, public variable or initialization path to a derived contract. CompositionComposition creates a “has-a” relationship. A contract stores an address or instance of another contract and calls it through an interface. interface IPriceOracle {
function price() external view returns (uint256);
}
contract PaymentPolicy {
IPriceOracle public immutable oracle;
constructor(IPriceOracle _oracle) {
oracle = _oracle;
}
function currentPrice() external view returns (uint256) {
return oracle.price();
}
}Composition usually makes dependencies more explicit and replaceable. It also introduces external-call risk, gas costs and the possibility that a dependency becomes unavailable or returns harmful data. | Design choice | Strength | Main risk | Suitable use |
|---|
| Inheritance | Shared behavior and modifiers | Hidden coupling and storage complexity | Common policy or lifecycle behavior | | Composition | Explicit replaceable dependency | External-call and availability risk | Oracles, token adapters and strategies | | Library | Reusable focused logic | Incorrect assumptions or delegate linking | Mathematics, validation and data structures | | Interface | Stable communication boundary | Does not guarantee behavior | Contract-to-contract integration |
A practical rule is to use inheritance for genuinely shared identity or policy, and composition when a component represents a replaceable service, strategy or external system. 4. Separation of concerns and interface designSeparation of concerns means that each module has a limited reason to change. Consider a community treasury. It may be divided into: - TreasuryVault: holds Ether or tokens and executes approved transfers.
- AccessController: records administrators and spending approvers.
- SpendingPolicy: checks per-transaction and periodic limits.
- Token adapter: normalizes interaction with a selected token standard.
The vault should not decide how a governance vote is conducted. Governance may authorize a payment, but the vault should independently verify the authorization and enforce its own safety rules. A good interface exposes behavior rather than implementation details. It should use precise names, custom errors where appropriate, events for important state transitions and minimal permissions. For example: interface IVault {
error Unauthorized();
error AmountExceedsLimit();
event PaymentExecuted(address indexed token, address indexed to, uint256 amount);
function executePayment(address token, address to, uint256 amount) external;
}Events are part of the operational interface. They support monitoring and audit trails, but they do not enforce security and should not be treated as a replacement for state. 5. Access control and authority boundariesAccess control determines who may call a function and under what conditions. Common models include: - ownership, in which one account has administrative authority;
- role-based access control, in which distinct roles have distinct permissions;
- multi-signature approval, in which several signers must authorize an action;
- timelocks, which delay execution and provide an opportunity for review;
- capability-based permissions, in which a narrowly scoped capability authorizes a specific action.
The principle of least privilege requires each account or module to receive only the permissions necessary for its role. A token-spending contract should not receive unrestricted administrative control over unrelated protocol functions. Authorization should be checked at the point where the sensitive state change occurs. For example, if a vault transfers assets, the vault—not only an upstream governance contract—should verify the caller or authorization proof. A robust design also considers the lifecycle of authority: initial setup, transfer, revocation, lost keys, emergency recovery and contract destruction or migration. The zero address should normally be rejected as an owner or privileged role. Administrative actions should emit events. 6. Checks-effects-interactionsThe checks-effects-interactions pattern reduces reentrancy risk by ordering operations as follows: - Checks: validate caller, parameters, balances and policy.
- Effects: update internal state.
- Interactions: call external contracts or transfer assets.
function withdraw(uint256 amount) external {
require(amount <= credits[msg.sender], "insufficient credit");
credits[msg.sender] -= amount; // effect before interaction
(bool ok, ) = payable(msg.sender).call{value: amount}("");
require(ok, "transfer failed"); // interaction
}If the recipient is a contract, its fallback function may execute arbitrary code and call back into the withdrawing contract. Decreasing the balance before the call prevents a straightforward repeated withdrawal. A reentrancy guard may provide additional defense, but it does not replace correct accounting and authorization. The pattern must be adapted carefully. If an external call is needed to calculate whether a transaction is valid, its result may be manipulated or become unavailable. Pull-payment designs, in which users withdraw funds themselves, often reduce the risk associated with pushing funds to many recipients. Other interaction risks include denial of service caused by a reverting recipient, gas griefing, malicious token callbacks and unexpected return-value behavior. Low-level calls should check their success status. Token operations should use well-tested wrappers when dealing with non-standard tokens. 7. A modular treasury implementationThe following illustrative design separates authorization from asset movement. It is intentionally simplified and is not production-ready without extensive testing and auditing. interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
}
contract Vault {
address public admin;
uint256 public limit;
error NotAdmin();
error LimitExceeded();
error TransferFailed();
event LimitChanged(uint256 newLimit);
event TokenPaid(address indexed token, address indexed recipient, uint256 amount);
constructor(address initialAdmin, uint256 initialLimit) {
require(initialAdmin != address(0), "zero admin");
admin = initialAdmin;
limit = initialLimit;
}
modifier onlyAdmin() {
if (msg.sender != admin) revert NotAdmin();
_;
}
function setLimit(uint256 newLimit) external onlyAdmin {
limit = newLimit;
emit LimitChanged(newLimit);
}
function payToken(address token, address recipient, uint256 amount)
external
onlyAdmin
{
if (amount > limit) revert LimitExceeded();
bool success = IERC20(token).transfer(recipient, amount);
if (!success) revert TransferFailed();
emit TokenPaid(token, recipient, amount);
}
}This example illustrates an interface, a focused vault responsibility, explicit authorization and an event. It also reveals limitations. The same admin controls both policy and execution; there is no multisignature approval, token allow-list, pause mechanism, spending period, protection against fee-on-transfer tokens or verification that token is a trusted contract. A professional design review must identify these limitations rather than assume that compilation implies safety. 8. Reusable patterns and audited componentsEstablished patterns provide tested solutions to recurring problems, but each must be evaluated in context. Checks-effects-interactions and pull paymentsThese patterns protect accounting around external calls. They are particularly relevant to Ether withdrawals, auction refunds and reward claims. Ownable and role-based accessOwnership is simple and appropriate for some small systems. Roles are preferable when administrators, pausers, minters and operators have different responsibilities. Role identifiers and assignment events should be documented. Pausable emergency controlA pause mechanism can limit damage during an incident. It must have a clearly governed pauser, define which operations are blocked and include a plan for unpausing. A pause controlled by one unprotected key can itself become a central failure point. Pull-based claimsRather than iterating over all recipients in one transaction, the contract records each recipient’s entitlement and lets recipients claim independently. This reduces loop-related gas and denial-of-service risk, although claim accounting must prevent double payment. Check-effects-interactions-compatible escrowAn escrow contract should record deposits and release conditions internally, update balances before transfers and make dispute authority explicit. Reusable components from well-known libraries can reduce implementation errors, but developers must verify compiler compatibility, version, license, initialization requirements, upgrade assumptions and known vulnerabilities. Copying code without understanding it transfers risk rather than removing it. 9. Upgradeability and storage concernsEthereum contract code is normally immutable after deployment. Upgradeable systems introduce a proxy that delegates calls to an implementation contract. The proxy stores state, while delegatecall executes implementation code in the proxy’s storage context. This arrangement creates special risks: - storage variables must retain compatible positions across upgrades;
- constructors do not initialize proxy storage, so initializer functions are required;
- an initializer must be callable only once;
- implementation and administrative addresses must be protected;
- an upgrade can change economic rules and should be governed transparently;
delegatecall executes code with the caller’s storage and balance context.
Upgradeability is therefore a governance decision, not merely a technical convenience. A timelock, multisignature control, public upgrade notice and emergency procedure may be appropriate. Some applications should prefer immutable contracts because users value predictable behavior over rapid modification. 10. Testing, auditing and documentationModularity improves verification when each component has explicit properties. Testing should include: - unit tests for each function and failure condition;
- integration tests for interfaces and token behavior;
- access-control tests using unauthorized accounts;
- invariant tests, such as conservation of balances;
- fuzz tests over amounts, addresses and call sequences;
- reentrancy and malicious-callback tests;
- deployment and initialization tests on a test network.
A useful invariant for a simple vault is that an internal liability cannot exceed assets reserved for it. The exact invariant depends on the application and must be stated precisely; vague claims such as “the contract is safe” are not testable. An audit should review trust assumptions, privileged roles, external calls, arithmetic, event completeness, denial-of-service paths, upgrade mechanisms and compiler settings. Static analysis tools can identify patterns, but they cannot determine whether the business policy is correct. Documentation should include a module map, state ownership table, permissions matrix, external dependency list, upgrade policy and emergency response plan. NatSpec comments should explain externally visible behavior and security assumptions, not merely repeat function names. 11. Applied case: modular community grant treasuryBackgroundThis illustrative case concerns a community that awards grants in an ERC-20 token. Members vote off-chain, and an authorized executor submits approved payments on-chain. ProblemAn initial monolithic contract stored proposals, counted votes, verified signatures, held tokens and executed transfers. It was difficult to audit because a change to voting logic could unintentionally affect asset custody. A single operator could also submit an arbitrary payment if authorization was not independently checked. AnalysisThe critical assets were held by the treasury, while voting and authorization were policy concerns. The system required a clear boundary between the decision process and execution. It also needed protection against repeated execution, unauthorized callers and unsafe token interactions. ApproachThe revised architecture used four modules: - Proposal registry: stores proposal identifiers, recipients, amounts and execution status.
- Authorization verifier: verifies that a proposal has the required approval, such as a multisignature record.
- Treasury vault: holds tokens and executes only authorized, unexecuted payments.
- Token adapter: provides a controlled interface for supported token behavior.
The vault marked a proposal as executed before performing the external token transfer. It rejected duplicate identifiers, zero recipients and amounts above policy limits. The authorization verifier was an address dependency represented by an interface, allowing it to be replaced through governed administration. The vault emitted an event containing the proposal identifier, token, recipient and amount. OutcomeThe modules could be tested separately. The registry tests focused on proposal lifecycle; verifier tests focused on authorization; vault tests focused on accounting and access control; integration tests checked the complete payment path. The architecture did not eliminate governance risk: a compromised authorization module could still approve harmful payments. However, it localized that risk and made the trust boundary visible. Learning points- Asset custody should have fewer permissions than policy management.
- A valid upstream decision does not remove the need for downstream checks.
- Interfaces make dependencies explicit but do not make them trustworthy.
- Event logs, tests and documentation are part of operational security.
- Modularity improves review only when responsibilities and assumptions are written down.
12. Limitations, ethics and governanceSmart-contract modularity can create a false sense of safety. More contracts mean more addresses, transactions and integration points. A failure in one module may propagate through the system, and users may not understand which administrator can alter a dependency. Developers have an ethical responsibility to disclose privileged powers, upgradeability, pause controls, oracle assumptions and known limitations. Interfaces should not disguise risks from users. Governance should consider who benefits from administrative control, how conflicts of interest are managed and how users can respond to an emergency. Regulatory classification may depend on the application, jurisdiction, assets and operating model. A modular architecture does not avoid obligations relating to consumer protection, financial services, privacy, sanctions or record keeping. Legal review is necessary for real deployments. Privacy also matters. Events and state on public blockchains are observable. A contract should not expose personal data merely because an event is convenient. Where identity is required, the design should minimize on-chain personal information and carefully evaluate off-chain attestations. Best-practice checklistBefore deployment, confirm that: - each module has one primary responsibility;
- state ownership and invariants are documented;
- interfaces are minimal and dependencies are validated;
- authorization is enforced at the sensitive operation;
- checks, effects and interactions are ordered safely;
- external-call failures and non-standard tokens are considered;
- privileged actions emit events and are governed appropriately;
- upgrade and initialization behavior is tested;
- reusable components are version-pinned, reviewed and understood;
- unit, integration, fuzz and adversarial tests have been completed;
- users can understand trust assumptions and administrative powers.
Chapter SummaryModular smart-contract design divides a blockchain application into components with focused responsibilities and explicit boundaries. Contracts implement behavior and own state; interfaces define communication boundaries; abstract contracts provide shared partial implementations; and libraries provide reusable logic. Inheritance is useful for genuinely shared policy, while composition is often better for replaceable external dependencies. A maintainable design applies separation of concerns, least privilege and clear invariants. Access control must be enforced where sensitive state changes occur. The checks-effects-interactions pattern reduces reentrancy risk by validating conditions, updating internal state and only then calling external contracts. Reusable audited components improve reliability but still require version, configuration and behavioral review. Upgradeability introduces storage-layout, initialization and governance risks. Testing should include unit, integration, invariant, fuzz and adversarial tests. Finally, modularity is a governance and ethical issue as well as a programming technique: users should know who controls each module, how upgrades occur and what assumptions the system makes. Key TermsModule: A component with a defined responsibility, interface and internal implementation. Cohesion: The degree to which responsibilities within a module belong together. Coupling: The degree of dependency between modules. Interface: A declaration of externally callable functions without implementation or storage. Abstract contract: A contract that may contain implementation but cannot be deployed while required functions remain unimplemented. Library: Reusable Solidity code, commonly used for stateless utilities or controlled data operations. Inheritance: A relationship in which a derived contract receives accessible behavior from a base contract. Composition: A design in which one contract uses another contract as a dependency. Separation of concerns: Dividing a system so that each component addresses a focused responsibility. Access control: Rules determining who may perform an operation. Least privilege: Granting only the authority necessary for a role or component. Reentrancy: Repeated entry into a contract during an unfinished external call. Checks-effects-interactions: A pattern that validates, updates state and then performs external calls. Invariant: A property that should remain true throughout valid contract execution. Delegatecall: An operation that executes another contract’s code using the caller’s storage and context. Upgradeability: The ability to change implementation behavior after deployment. Pull payment: A design in which a recipient claims recorded funds rather than receiving a pushed transfer. Token adapter: A component that provides controlled interaction with a token implementation. Timelock: A mechanism that delays an approved action before execution. Review QuestionsShort Answer Questions- What is the principal difference between an interface and an abstract contract?
- Why is composition often suitable for oracle dependencies?
- State the three stages of checks-effects-interactions.
- What does the principle of least privilege require?
- Why do upgradeable contracts require special attention to storage layout?
Long Answer Questions- Compare inheritance and composition, including their advantages, risks and appropriate uses.
- Explain how separation of concerns can improve the security and auditability of a token treasury.
- Describe the role of libraries and audited reusable components in Solidity development.
- Explain why an interface does not guarantee that a dependency is trustworthy.
- Discuss the technical and governance risks of proxy-based upgradeability.
Analytical / Application Questions- Design a modular architecture for a decentralized grant system. Identify its modules, state owners, interfaces and authority boundaries.
- A withdrawal function transfers Ether before reducing the user’s recorded balance. Analyze the vulnerability and propose a corrected execution sequence.
- A protocol uses one administrator for pausing, upgrading, changing fees and transferring assets. Evaluate this design using least privilege and propose a safer governance model.
Multiple Choice Questions with AnswersQuestion 1Which Solidity construct primarily defines an external contract boundary without implementation or storage? A. Interface B. Library C. Constructor D. Modifier Correct Answer: A. Interface Question 2Which relationship is represented when a contract stores and calls an oracle through an interface? A. Inheritance B. Composition C. Overloading D. Encapsulation failure Correct Answer: B. Composition Question 3In checks-effects-interactions, what should normally occur immediately before an external call? A. Deployment of a new proxy B. Updating relevant internal state C. Changing the compiler version D. Removing access control Correct Answer: B. Updating relevant internal state Question 4What is the main purpose of least-privilege access control? A. To give every user administrative rights B. To minimize the authority granted to each role C. To eliminate all contract interfaces D. To guarantee that external calls cannot revert Correct Answer: B. To minimize the authority granted to each role Question 5Which feature may be provided by an abstract contract? A. Only immutable constants B. Shared implemented behavior and required functions C. Automatic protection from reentrancy D. A guarantee that derived storage is safe Correct Answer: B. Shared implemented behavior and required functions Question 6Why should a low-level Ether call have its success status checked? A. The call may fail without automatically reverting the whole intended operation B. It always returns a token balance C. It changes the Solidity compiler version D. It prevents every form of denial of service Correct Answer: A. The call may fail without automatically reverting the whole intended operation Question 7Which design is generally most appropriate for avoiding one transaction that must pay a very large list of recipients? A. Push payment with an unbounded loop B. Pull payment in which recipients claim recorded amounts C. Removing all accounting state D. Using inheritance instead of a balance mapping Correct Answer: B. Pull payment in which recipients claim recorded amounts Question 8What special issue is associated with proxy-based upgradeability? A. Ether cannot be transferred from a proxy B. Storage layout must remain compatible across implementations C. Interfaces cannot be used by proxies D. Events are impossible to emit Correct Answer: B. Storage layout must remain compatible across implementations Question 9What does high cohesion mean in modular design? A. A module contains closely related responsibilities B. Every module depends on every other module C. All functions are public D. The system has no external calls Correct Answer: A. A module contains closely related responsibilities Question 10Which statement about reusable audited components is most accurate? A. Reuse eliminates the need for application-specific testing B. Reuse is helpful, but versions, configuration and assumptions still require review C. A library automatically validates every address supplied to it D. Audited code cannot contain governance risks Correct Answer: B. Reuse is helpful, but versions, configuration and assumptions still require review
|