Structs & Libs

engineeringsoliditysmart-contractsarchitecture

Solidity contracts tend to grow in one of two directions. The first is inheritance: extract shared behavior into base contracts, override virtual functions, build a hierarchy. The second is composition: define data as structs, write logic in libraries that operate on those structs, keep your contracts thin.

I’ve worked with both. At Goldfinch, we used structs and libs across the protocol for loan accounting, credit line management, and pool mechanics. Aave V3, Synthetix V3, and most mature DeFi protocols converge on something similar. This post covers why this is my preferred pattern for smart contracts.

Inheritance vs. Composition

This maps to a fundamental question in software design: is-a or uses-a?

Inheritance says “this contract is a base contract.” It carries all of the parent’s storage, implementations, and interface. Composition says “this contract uses these libraries to operate on this data.” The contract owns the data and selects which operations to apply.

InheritanceStructs & Libs
Code reuseImplicit: child inherits everythingExplicit: contract opts into specific functions
State couplingShared storage namespace across the hierarchyLibraries are stateless; data passed explicitly
UpgradesStorage gaps, careful base contract orderingStruct is the schema; logic swaps freely
Audit surfaceMust understand the full hierarchyEach library is self-contained
TestingRequires deploying the full contractLibrary functions testable in isolation
Contract sizeAll inherited code compiles into the childInternal libs inline; external ones deploy separately
FlexibilityLocked into the hierarchyAttach any library to any struct

Inheritance works for small contracts and standard interfaces (ERC20, Ownable). At scale, its costs compound. Storage layout becomes fragile. Overridden functions create implicit control flow. Every line of inherited code counts toward the child’s bytecode.

Composition is more verbose, but that’s a benefit for smart contracts. Eliminating all assumptions is critical for writing secure code. It also leads to better isolation, easier fuzz testing, and clarity when reviewing.

The Pattern

Two layers:

Struct & Lib - define the struct and the functions that operate on it. This is roughly similar to how C “attaches” methods to structs. The struct and its logic are tied together, so they live together.

Main contract - holds state, delegates to libraries, handles access control and external calls. Minimal business logic.

Here’s a simple example. A Gong contract that tracks how many times it’s been rung:

GongLib.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

library GongLib {
    struct State {
        uint256 count;
        uint256 lastRungAt;
    }

    function ring(State storage state) internal {
        state.count += 1;
        state.lastRungAt = block.number;
    }
}
Gong.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import { GongLib } from "./GongLib.sol";

contract Gong {
    using GongLib for GongLib.State;

    GongLib.State internal _state;

    function ring() external {
        _state.ring();
    }

    function count() external view returns (uint256) {
        return _state.count;
    }

    function lastRungAt() external view returns (uint256) {
        return _state.lastRungAt;
    }
}

using GongLib for GongLib.State attaches the library’s functions to the struct type. _state.ring() reads like a method call, but the compiler passes the struct as the first storage argument. No hidden state: the function can only touch what you hand it.

Storage Libraries

The basic pattern above still requires the contract to declare and manage the struct in its own storage. You can take it further: let the library own its storage entirely.

GongStorage.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

library GongStorage {
    struct Store {
        uint256 count;
        uint256 lastRungAt;
    }

    function store() internal pure returns (Store storage s) {
        bytes32 slot = keccak256("GongStorage");
        assembly { s.slot := slot }
    }

    function ring() internal {
        Store storage s = store();
        s.count += 1;
        s.lastRungAt = block.number;
    }

    function getCount() internal view returns (uint256) {
        return store().count;
    }

    function getLastRungAt() internal view returns (uint256) {
        return store().lastRungAt;
    }
}

The library manages its own struct at a deterministic storage slot. Now the contract doesn’t declare any storage variables at all:

Gong.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import { GongStorage } from "./GongStorage.sol";

contract Gong {
    function ring() external {
        GongStorage.ring();
    }

    function count() external view returns (uint256) {
        return GongStorage.getCount();
    }

    function lastRungAt() external view returns (uint256) {
        return GongStorage.getLastRungAt();
    }
}

The contract is just a thin shell. All state and logic live in the library. This is the same capability that inheritance gives you, but without the baggage:

  • No shared storage namespace. Each library’s struct lives at its own slot. No storage gaps, no collision risk between base contracts.
  • No linearization complexity. Solidity’s C3 linearization determines the order of inherited constructors and overrides. With libraries, there’s nothing to linearize.
  • No implicit overrides. Inheritance lets a child silently override a parent’s function. Libraries are explicit: you call what you call.
  • Clean upgrades. Each library’s storage is at a fixed slot. Add a new library, remove one, reorder your code, existing storage is unaffected.

This is the pattern Synthetix V3 uses. Their modules define storage structs with namespaced slots and encapsulate all the logic for that domain. The router proxy merges them into a single contract, but each module is independently developed and tested.

Internal vs. External Libraries

When a library function takes a struct storage pointer, it can only touch that struct’s slots. It can’t reach into unrelated mappings or modify global state unless you explicitly pass it in. The function signature is the complete picture of what data is involved.

This matters for gas too. You can profile GongStorage.ring() and know exactly which storage reads and writes it performs. No inherited state being implicitly accessed somewhere up the hierarchy.

internal library functions get inlined by the compiler directly into the calling contract’s bytecode. No extra deployment, no call overhead, same gas profile as if you wrote it all in one file. This is what you want most of the time.

If your contract is hitting the 24KB bytecode limit, you can mark library functions public or external. The compiler deploys the library as a separate contract and calls into it via DELEGATECALL. Here’s the key detail: DELEGATECALL executes the library’s code in the context of the calling contract. It uses the caller’s storage, the caller’s msg.sender, the caller’s msg.value. So GongStorage.ring() still writes to Gong’s storage slots, not the library’s own storage. The storage library pattern is safe regardless of whether the library is inlined or deployed separately.

In Production

Aave V3’s Pool.sol imports SupplyLogic, BorrowLogic, LiquidationLogic, and others. Aave splits structs into a shared DataTypes.sol because multiple libraries operate on the same types, which makes sense at that scale. For most projects, keeping the struct in its library is simpler. Either way, the entry point is a thin coordinator, and auditors review liquidation math without loading the entire protocol.

Synthetix V3 uses namespaced storage structs in modules behind a router proxy. Different mechanics, same principle: data definitions separate from behavior, entry points thin.

At Goldfinch, we used this across a $100M lending system. Each domain got its own library operating on shared struct definitions. Auditors could scope reviews precisely: here’s the credit line math, here are the struct fields it touches, here are the invariants. That specificity made audits faster and more thorough.

Takeaway

Structs and libs is composition over inheritance for Solidity. Define your data model, write focused libraries that operate on it, keep contracts thin. With storage libraries, you get full encapsulation: each library owns its own slice of state at a deterministic slot, composable across any contract without the storage fragility and linearization complexity of deep inheritance.

With structs & libs, we get exactly what we want in a high-stakes programming environment: explicit code execution, reduction of assumptions, and clear testing interfaces.

Further Reading