Structuring a Solidity protocol codebase: interfaces, modules and libraries
A practical layout for a Solidity protocol: interfaces, core and periphery, modules that own their state, libraries and inheritance, and why each matters.
· 6 min read
- Solidity
- Architecture
- Upgradeable contracts
Most smart contract bugs sit in a single function, but the shape of the codebase around it often makes them possible: state written from five places, an inheritance tree nobody can draw, or a helper contract holding approvals nobody thought about. A clear structure makes a protocol cheaper to audit, safer to upgrade and easier for integrators to build on.
This is the structure we use for new Solidity protocols, and the reasoning behind each part.
A directory layout
src/
interfaces/ IVault.sol, IOracle.sol external API: functions, events, errors
libraries/ FeeMath.sol, Units.sol pure logic, no storage
modules/ FeeModule.sol, PausableModule.sol abstract contracts that each own one piece of state
core/ Vault.sol, Registry.sol deployable contracts that hold funds and state
periphery/ Router.sol, Lens.sol helpers that hold nothing between transactions
test/ mirrors src/, plus invariant/ and fork/
script/ deployment and upgrade scripts
The exact names matter less than the boundaries. Each folder answers three questions: who calls this, does it hold state, and does it hold funds?
Pin your compiler version and dependency versions in foundry.toml and foundry.lock, so every build compiles the same code with the same compiler.
Interfaces first
Write the interfaces before the implementations, including every event and custom error. Interfaces are the protocol’s public API. Integrators import them, core contracts implement them, and the periphery calls through them.
This keeps changes visible. Implementations can change behind an interface during development without integrators noticing, while a change to the interface itself is a deliberate API change that shows up in review. Putting events and errors in the interface also means indexers and front ends depend on one small file instead of the implementation.
Core and periphery
Split contracts that hold funds or protocol state from contracts that only make the core easier to use.
- Core contracts are small, change rarely and get the most review. They expose the minimum the protocol needs: deposit, withdraw, borrow, liquidate.
- Periphery contracts hold no funds between transactions: routers that batch calls, lenses that aggregate views, migration helpers. A buggy lens can simply be replaced.
Uniswap popularised this split, and it pays off: every feature request starts with the question “does this need to be in core?”, and the answer is usually no.
One caution: a router that receives token approvals is security-critical, even though it holds nothing. Approval-draining bugs in routers and aggregators are a recurring class of large exploit, and deploying a fixed router doesn’t revoke approvals users gave the old one. Prefer Permit2 or exact per-transaction approvals, never make arbitrary external calls with users’ approvals, and give the router a way to pause.
Modules own their state
Within a core contract, group related state and logic into abstract contracts, one per concern: fees, pausing, accounting. A module:
- keeps its state private, in its own storage namespace;
- exposes
internalfunctions for the deployable contract to call; - offers
internal virtualhooks where the contract needs to customise behaviour.
abstract contract FeeModule is IVaultFees {
/// @custom:storage-location erc7201:acme.vault.fees
struct FeeStorage {
uint16 feeBps;
address recipient;
}
uint16 private constant MAX_FEE_BPS = 1_000;
function _setFee(uint16 feeBps, address recipient) internal {
require(feeBps <= MAX_FEE_BPS, FeeTooHigh(feeBps));
FeeStorage storage $ = _feeStorage();
($.feeBps, $.recipient) = (feeBps, recipient);
emit FeeUpdated(feeBps, recipient);
}
function _quoteFee(uint256 amount) internal view returns (uint256 fee, address recipient) {
FeeStorage storage $ = _feeStorage();
return (amount * $.feeBps / 10_000, $.recipient);
}
// _feeStorage(): a private accessor returning the struct at erc7201("acme.vault.fees")
}
Because only FeeModule can reach FeeStorage, a reviewer who wants to know every way the fee can change reads one file. Because the state lives in its own namespace, adding, removing or reordering modules in a later upgrade doesn’t move anyone else’s storage. The accessor and the rules for evolving the struct are covered in one storage namespace per module.
The deployable contract composes modules and contains the protocol’s flows:
contract Vault is IVault, FeeModule, PausableModule, UUPSUpgradeable, AccessManagedUpgradeable {
function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
_requireNotPaused();
(uint256 fee, address recipient) = _quoteFee(assets);
// ...
}
function _authorizeUpgrade(address) internal override restricted {}
}
The last line is the most security-critical in the contract. Our comparison of proxy patterns explains why.
Keep inheritance shallow
Solidity resolves multiple inheritance with C3 linearisation. The order you list base contracts decides the order constructors run and what each super call resolves to. Initialisers in upgradeable contracts are ordinary functions and run in the order you call them, so call them in linearised order yourself. All of this is easy to get wrong and hard to review when the tree is deep.
Rules that keep it manageable:
- One level of modules. Modules inherit from interfaces or nothing, not from each other. If two modules need to interact, the deployable contract connects them through hooks.
- List bases from most general to most specific, as Solidity requires, and keep the same order across contracts.
- Write
overrideevery time, even where Solidity 0.8.8 and later lets you omit it for a function that implements a single interface. Where several bases define the same function, the compiler forcesoverride(A, B). Treat that as a prompt to check whatsuperresolves to.
Libraries for pure logic
Use libraries for logic that doesn’t need storage: fixed-point maths, unit conversions, encoding, validation. internal library functions are compiled into the calling contract and called with an internal jump, so they cost the same as calling one of your own internal functions. They can also be unit-tested and fuzzed in isolation, which is far quicker than testing them through the whole protocol.
User-defined value types belong here too. type Shares is uint256 and type Assets is uint256, with a library of conversions, makes it a compile error to pass shares where assets are expected, a mistake that is otherwise easy to make in vault code.
public and external library functions are deployed separately and called with delegatecall. That helps a contract stay under the code size limit (24 KiB on mainnet under EIP-170, which the upcoming Glamsterdam upgrade is expected to raise), but it adds a deployment step and an address to manage. Split responsibilities across contracts before reaching for linked libraries.
Order within a file
Follow the Solidity style guide’s order inside a contract, so reviewers know where to look:
- Type declarations (structs, enums)
- State variables, constants and immutables
- Events and errors (if not in the interface)
- Modifiers
- Constructor, then
receiveandfallback external, thenpublic, theninternal, thenprivatefunctions
Within each group, put state-changing functions before view and pure ones.
Cross-cutting concerns in one place
Permissions. A single OpenZeppelin AccessManager that every core contract defers to with the restricted modifier gives you one place to see who can do what, and one place to put a timelock. Scattering onlyOwner across contracts with different owners is how protocols end up with an admin key nobody remembers.
Reentrancy. Use one guard, in one module. Since Cancun, a transient-storage guard such as OpenZeppelin’s ReentrancyGuardTransient is cheap enough to apply everywhere it’s needed. Put nonReentrant on every external entry point that moves value, and have those functions call private functions rather than each other, because two guarded functions can’t call one another. Guard views that integrators price from, such as share price, with nonReentrantView, to stop read-only reentrancy.
A checklist
- Could an integrator build against the interfaces folder alone?
- Is there anything in periphery that holds funds or arbitrary approvals?
- For each piece of state, is there exactly one module that writes it?
- Can you draw the inheritance tree for each deployable contract from memory?
- Does every unit mismatch you’ve worried about have a type?
- Is there exactly one answer to “who can call this?”
Test structure should mirror all of this. Unit tests per module and library, then invariant tests against the deployed composition.
If you’re starting a protocol and want help with its architecture, see our Ethereum smart contract development service.