Applied Patterns
Six patterns you'll see again and again in modern DeFi protocols. For each: the shape, where it's used, what the tradeoffs are, what a senior would say when asked to defend it.
1. Singleton vs factory
In a factory pattern, each market is its own contract; the factory deploys them. In a singleton, one contract holds all markets in storage, keyed by market parameters.
// Singleton — Morpho Blue / Euler v2 style
contract LendingCore {
mapping(Id => Market) public market;
mapping(Id => mapping(address => Position)) public position;
function createMarket(MarketParams calldata p) external {
Id id = p.toId(); // keccak256(abi.encode(p))
if (market[id].lastUpdate != 0) revert AlreadyCreated();
if (!isLltvEnabled[p.lltv]) revert LltvNotEnabled();
if (!isIrmEnabled[p.irm]) revert IrmNotEnabled();
market[id].lastUpdate = uint128(block.timestamp);
emit CreateMarket(id, p);
}
function supply(MarketParams calldata p, uint256 assets, address onBehalf)
external returns (uint256 shares)
{
Id id = p.toId();
_accrueInterest(market[id], p);
// ...
}
}
| Axis | Singleton | Factory |
|---|---|---|
| Gas (cross-market ops) | Cheap — one SLOAD context | Expensive — external CALL per market |
| Composability | Strong — multi-market in one call | Limited — each call to a separate contract |
| Bug blast radius | All markets — terrifying | Per-market — bug fixable by deploying new market |
| Storage layout drift | Cannot change once deployed | Each new market can use a new contract version |
| Discoverability | Anyone reads all markets from one address | Need a registry |
Singleton is right when (a) the core can be made small and immutable, (b) markets are isolated by parameters so bug surface is bounded, (c) composability matters. Factory is right when markets need to evolve independently, or when each market is large enough to be its own product.
2. ERC-4626 vault adapters
ERC-4626 is the tokenized-vault standard: deposit an underlying, get shares; redeem shares, get underlying. It is the universal adapter between user-facing capital and protocol-level primitives. Use it everywhere the protocol holds yield-bearing or stakeable assets.
abstract contract ERC4626 is ERC20 {
IERC20 public immutable asset;
function totalAssets() public view virtual returns (uint256);
function deposit(uint256 assets, address receiver) public virtual returns (uint256 shares) {
shares = previewDeposit(assets);
if (shares == 0) revert ZeroShares();
asset.safeTransferFrom(msg.sender, address(this), assets);
_mint(receiver, shares);
emit Deposit(msg.sender, receiver, assets, shares);
}
function previewDeposit(uint256 assets) public view returns (uint256) {
return _convertToShares(assets, MathUpgradeable.Rounding.Down);
}
function _convertToShares(uint256 assets, Rounding r) internal view returns (uint256) {
uint256 supply = totalSupply();
return supply == 0
? assets
: assets.mulDiv(supply, totalAssets(), r);
}
}
Production callouts:
- Inflation attack. The first depositor can mint 1 share, donate underlying, and dilute later depositors who round down to 0 shares. Mitigations: virtual shares (OpenZeppelin), dead shares (Solady), or refusing the first deposit below a minimum.
- Rounding everywhere. Round shares down on deposit; round assets up on redeem. Round consistently across all
preview*/convert*functions. - Reentrancy on rebasing or fee-on-transfer underlying. Use
safeTransferFrom, measure balance before/after, and reject tokens that don't conform. - Curated risk vaults. A common pattern in modern lending: a vault holds depositor capital and allocates across multiple isolated markets according to a curator's policy. Users see one share token; risk is curated.
3. Periphery / router separation
The core is small and immutable. User-facing UX — multi-step transactions, slippage protection, permit handling, signature flows, multicall — lives in a periphery or router contract that the core does not know about.
// Periphery — bundles permit + supply + borrow into one user transaction
contract LendingBundler {
LendingCore public immutable core;
function permitAndBorrow(
MarketParams calldata p,
uint256 collateral,
uint256 borrowAmount,
PermitData calldata permit
) external {
IERC20Permit(p.collateralToken).permit(
msg.sender, address(this),
permit.value, permit.deadline,
permit.v, permit.r, permit.s
);
IERC20(p.collateralToken).safeTransferFrom(msg.sender, address(this), collateral);
IERC20(p.collateralToken).safeApprove(address(core), collateral);
core.supplyCollateral(p, collateral, msg.sender, "");
core.borrow(p, borrowAmount, 0, msg.sender, msg.sender);
}
}
Trade-offs:
- Pro: periphery is upgradable without touching the core; bugs are contained to UX, not protocol.
- Con: users must approve the periphery, which introduces a "trust the bundler" surface (mitigated by reviewing the bundler code, not granting infinite approvals, or using transient approval patterns).
- Senior reflex: in interviews, distinguish what belongs in the core (invariants, money) from what belongs in periphery (UX, ergonomics). Never let UX concerns push you to add knobs to the core.
4. Callbacks & flash loans
A callback hands control to the caller mid-transaction, then expects state to be settled by the time control returns. This is how flash loans, leverage routers, and atomic liquidation-with-swap all work.
// Generic callback pattern — borrow with a callback to swap the loan into collateral
function borrow(
MarketParams calldata p,
uint256 assets,
uint256 shares,
address onBehalf,
address receiver,
bytes calldata data
) external returns (uint256, uint256) {
Id id = p.toId();
_accrueInterest(market[id], p);
// Effects first — credit shares, deduct from market
(assets, shares) = _settleBorrow(id, assets, shares, onBehalf);
// Push loan tokens to receiver
IERC20(p.loanToken).safeTransfer(receiver, assets);
// Optional callback — receiver can use loan tokens, deposit collateral, etc.
if (data.length != 0) IMorphoBorrowCallback(msg.sender).onMorphoBorrow(assets, data);
// Check health AFTER the callback
if (!_isHealthy(id, p, onBehalf)) revert UnhealthyPosition();
return (assets, shares);
}
The critical pattern: health check after the callback. The callback may deposit collateral, swap, or otherwise restore the position. The core trusts only the post-callback invariant.
- Reentrancy: callbacks intentionally reenter. The protections are CEI + the post-callback invariant check, not a blanket
nonReentrant. - Read-only reentrancy: while a callback is in flight, view functions may return inconsistent state. Other protocols reading you as an oracle can be fooled. Either expose a "do not read me right now" flag, or guarantee all views are consistent across the callback boundary.
- Authorization in callback: the callback runs as
onBehalfof the original caller; make sure permissions are checked at entry, not delegated.
5. Upgradeable vs immutable
The DeFi community has been moving away from upgradeable cores. The fashion in 2026 is immutable core + upgradeable periphery. But the trade-off is real.
| Pattern | How | Pros | Cons |
|---|---|---|---|
| Immutable | Deploy once, never upgrade. Bugs require redeploy + migrate. | No upgrade-key attack surface; users trust the address forever. | Bugs are permanent; migrations are painful. |
| Transparent proxy | Proxy delegatecalls into implementation. Admin can replace. | Battle-tested; clear admin separation. | Larger contract; admin power is real. |
| UUPS | Upgrade logic lives in implementation, not proxy. | Smaller proxy; cheaper deploys. | Forget to include upgrade logic → bricked. |
| Beacon | Many instances share one implementation pointer. | Mass upgrades across N markets in one tx. | Mass blast radius if upgrade is bad. |
| Diamond (EIP-2535) | Modular facets, each with its own selectors. | Can grow past contract-size limit. | Complex; storage collisions are nasty. |
For a small core that has been audited heavily, immutable is the strongest signal of trust you can offer users. Move all upgradability to vaults and periphery. If you must upgrade the core, use a transparent proxy with a long timelock, and accept that you have just made your governance key the most valuable target on chain.
6. Governance-gated knobs vs trustless parameters
Every parameter is either set at construction and frozen (trustless), changeable via governance vote behind a timelock (governed), or changeable by a guardian for emergencies (privileged).
// Trustless — set in market params, immutable for life of market
struct MarketParams {
address loanToken;
address collateralToken;
address oracle;
address irm;
uint256 lltv;
}
// Governed — list of allowed values, owner-controlled, behind timelock
mapping(address => bool) public isIrmEnabled;
mapping(uint256 => bool) public isLltvEnabled;
function enableIrm(address irm) external onlyOwner {
isIrmEnabled[irm] = true;
emit EnableIrm(irm);
}
// Emergency — guardian can pause without timelock
modifier whenNotPaused() {
if (paused) revert Paused();
_;
}
function pause() external onlyGuardian { paused = true; }
The senior taxonomy:
- Trustless: LLTV, oracle, IRM for a given market. Once a user supplies, the rules cannot move under them.
- Allow-list governance: the set of LLTVs / IRMs / fee recipients that any market may use. Slow, deliberate, timelocked.
- Guardian emergency: pause / unpause. Fast, narrow, with no economic upside (a guardian who pauses cannot steal — they can only stop things).
A clean separation between these three is one of the cleanest signals of senior protocol design. Conversely, a single owner key that can change LLTV on a live market is the kind of red flag that gets a protocol rejected by serious LPs.
7. Permissioned vs permissionless market creation
Who is allowed to create a new market on the protocol?
| Model | Mechanics | Used by |
|---|---|---|
| Fully permissionless | Anyone can call createMarket with any allowed params (from the governance allow-list). | Morpho Blue, Euler v2 vault-pairs |
| Curator-permissioned | Markets exist on-chain but capital is only routed via curated vaults that choose which to allocate to. | Risk vaults atop permissionless cores |
| Governance-listed | Each market is a governance proposal. | Aave v3, Compound v2 |
| Hybrid | Permissionless creation; risk parameters set per market by a risk DAO. | Some isolated-pool markets |
The deeper question: where does risk curation live? Permissionless creation does not eliminate the need for risk curation — it relocates it. Vaults that allocate across markets compete on returns and risk; users pick a vault, not a market. This pushes a complex, opinionated job out of the protocol core and into a competitive market.
"Permissionless markets + curated vaults" is the dominant new design pattern in lending. Defend it by noting: (a) it shrinks the core, (b) it makes curation economically incentivized, (c) it lets the protocol stay neutral while users still get curated UX. Defend the alternative by noting: (a) most users do not pick vaults thoughtfully, (b) protocol reputation suffers from every bad market on it, (c) governance-listed markets are easier to communicate.