Why library API design matters

A poorly designed Solidity library can create subtle bugs even when the implementation is correct. Common problems include:

  • ambiguous function names that hide side effects
  • overly broad parameter lists that encourage misuse
  • returning raw booleans instead of meaningful outcomes
  • mixing validation, state mutation, and external calls in one helper
  • exposing internal details that later become hard to change

Unlike off-chain code, Solidity APIs are expensive to refactor once deployed. If a library is used by multiple contracts, an awkward interface can become a permanent liability. Good API design reduces cognitive load for developers and makes audits easier.

Choose the right library shape

Solidity libraries typically fall into two categories:

  1. Pure utility libraries
  2. Stateless helpers such as math, encoding, or validation functions.

  1. State-aware libraries
  2. Libraries that operate on storage structs passed in by reference.

Each category benefits from different API conventions.

Pure utility libraries

These should be small, deterministic, and easy to reason about. Prefer pure functions whenever possible, and keep names descriptive.

library FeeMath {
    uint256 internal constant BPS_DENOMINATOR = 10_000;

    function calculateFee(uint256 amount, uint256 feeBps) internal pure returns (uint256) {
        require(feeBps <= BPS_DENOMINATOR, "FeeMath: invalid fee");
        return (amount * feeBps) / BPS_DENOMINATOR;
    }
}

This API is simple because:

  • the inputs are explicit
  • the output is deterministic
  • the function name describes the exact operation
  • validation is local and obvious

State-aware libraries

When a library mutates a struct in storage, the API should make that mutation obvious. Passing a storage pointer is powerful, but it also hides state changes if the naming is unclear.

library VaultLib {
    struct Vault {
        uint256 totalDeposits;
        mapping(address => uint256) balances;
    }

    function deposit(Vault storage vault, address user, uint256 amount) internal {
        require(amount > 0, "VaultLib: zero amount");
        vault.totalDeposits += amount;
        vault.balances[user] += amount;
    }
}

This pattern works well because the first parameter signals that the function mutates storage. The function name also reads like an action, not a generic helper.

Make side effects obvious

A good library API should tell the caller whether it reads data, mutates state, or may revert. Solidity does not have a formal naming convention enforced by the compiler, so your team needs one.

A practical convention is:

  • get... or read... for view-like accessors
  • calculate... or compute... for pure transformations
  • validate... for checks that may revert
  • action verbs like deposit, mint, burn, lock, or settle for state changes

Example: separating validation from mutation

Instead of combining everything into one large function, split the concerns:

library OrderLib {
    struct Order {
        address maker;
        uint256 amount;
        bool filled;
    }

    function validate(Order memory order) internal pure {
        require(order.maker != address(0), "OrderLib: no maker");
        require(order.amount > 0, "OrderLib: zero amount");
    }

    function fill(Order storage order) internal {
        require(!order.filled, "OrderLib: already filled");
        order.filled = true;
    }
}

This separation makes the call flow easier to audit. Validation can be reused in tests or preflight checks, while mutation remains narrowly scoped.

Prefer explicit inputs over hidden context

Library functions should not depend on implicit assumptions about caller state. If a function needs a threshold, a token address, or a precision constant, pass it in or define it as a clear constant inside the library.

Good API design principles

PrincipleBetter choiceWhy it helps
Explicit dependenciesPass required values as parametersEasier to test and reuse
Narrow scopeUse the minimum needed inputsReduces accidental coupling
Clear return valuesReturn structured outcomes when usefulAvoids guesswork
Stable namingUse domain terms consistentlyImproves readability across contracts

For example, avoid a helper that silently reads from unrelated storage in the calling contract. That makes the library harder to reuse and harder to verify.

Use custom data types only when they improve clarity

Solidity libraries often work with structs, but overusing nested structs can make APIs cumbersome. A good rule is to introduce a struct when it groups values that naturally belong together and are frequently passed together.

Good use of a struct

library PricingLib {
    struct Quote {
        uint256 baseAmount;
        uint256 feeAmount;
        uint256 totalAmount;
    }

    function quote(uint256 baseAmount, uint256 feeBps) internal pure returns (Quote memory) {
        uint256 feeAmount = (baseAmount * feeBps) / 10_000;
        return Quote({
            baseAmount: baseAmount,
            feeAmount: feeAmount,
            totalAmount: baseAmount + feeAmount
        });
    }
}

This is better than returning three loosely related values without names. The struct documents the meaning of each field and reduces call-site confusion.

When not to use a struct

Avoid structs if:

  • the function returns only one or two simple values
  • the struct is only used once and adds ceremony
  • the fields are not conceptually related

In those cases, a tuple or a single return value is often clearer.

Design return values for downstream contracts

A library API should be easy to consume from another contract. That means return values should be meaningful and stable.

Prefer informative outcomes

Instead of returning bool, consider returning the computed value or a status code encoded in a clear type. Booleans often hide why something failed or what changed.

For example, a library that checks whether a withdrawal is allowed should not just return true or false if the caller needs more context.

library WithdrawalLib {
    function maxWithdrawable(uint256 balance, uint256 lockedAmount) internal pure returns (uint256) {
        if (balance <= lockedAmount) {
            return 0;
        }
        return balance - lockedAmount;
    }
}

This API is more useful because the caller gets the exact amount it can use. The contract can then decide whether to revert, cap the withdrawal, or display the value to a user.

Keep library functions small and composable

A library should usually do one thing well. If a function validates, mutates, emits events, and performs external calls, it is probably too large for a library helper.

A practical decomposition

Suppose you are implementing a staking system. Instead of one monolithic function, split the logic:

  • validateStakeAmount
  • previewRewards
  • recordStake
  • updateRewardDebt

Each helper has a narrow purpose. That makes unit testing easier and reduces the chance that a future change breaks unrelated behavior.

Benefits of composability

  • easier to audit each step
  • simpler to reuse in different flows
  • fewer hidden dependencies
  • clearer failure points during debugging

Avoid exposing unstable implementation details

A library API becomes part of your contract’s public design, even if the functions are internal. If other contracts depend on the exact parameter order, return shape, or storage layout, changing the library later can be painful.

Design for future evolution

Use abstraction boundaries that reflect business concepts, not temporary implementation details. For example, a library for a lending protocol should expose concepts like:

  • collateral
  • debt
  • liquidation threshold
  • health factor

It should not expose temporary internal counters unless they are part of the domain model.

Example of a stable API

library HealthFactorLib {
    function compute(
        uint256 collateralValue,
        uint256 debtValue,
        uint256 liquidationThresholdBps
    ) internal pure returns (uint256) {
        require(debtValue > 0, "HealthFactorLib: no debt");
        return (collateralValue * liquidationThresholdBps) / debtValue;
    }
}

This API is understandable to protocol developers and remains stable even if the internal formula changes later.

Document assumptions directly in the code

Library APIs should state their expectations clearly. Solidity does not have rich function annotations, so comments and naming do important work.

Document:

  • whether zero values are allowed
  • whether inputs must be sorted or normalized
  • whether the function may revert
  • whether the function assumes token decimals or fixed-point precision
  • whether the caller must perform checks first

A short NatSpec comment can prevent misuse:

/// @notice Returns the amount after applying a basis-point fee.
/// @dev Reverts if feeBps is greater than 10_000.
function calculateFee(uint256 amount, uint256 feeBps) internal pure returns (uint256) {
    require(feeBps <= 10_000, "FeeMath: invalid fee");
    return (amount * feeBps) / 10_000;
}

This is especially useful when a library is reused across multiple teams or repositories.

Be consistent with error behavior

A library should not surprise callers with inconsistent revert patterns. If one function reverts on invalid input, similar functions should do the same unless there is a strong reason not to.

Common approaches

PatternBest forTradeoff
Revert on invalid inputInvariants and critical protocol logicLess flexible for optional flows
Return sentinel valuesPreview functions and UI helpersCallers must check results carefully
Return structured resultsComplex decision logicSlightly more verbose

For protocol-critical logic, reverting is usually the safest choice because it prevents silent failure. For read-only helpers used in frontends or simulations, returning a sentinel value like 0 can be acceptable if documented.

Test the API, not just the implementation

When you design a library API, test how it behaves from the caller’s perspective. Focus on:

  • valid input paths
  • invalid input paths
  • boundary values
  • repeated calls
  • storage mutation effects
  • assumptions about zero values and overflow behavior

A useful pattern is to write tests that mirror real integration points. If a staking contract uses a library, test the staking contract’s behavior through the library API rather than only testing the library in isolation.

A practical checklist for library API design

Before finalizing a Solidity library, review the following:

  • Does the function name accurately describe what it does?
  • Are side effects obvious from the signature and naming?
  • Are inputs explicit and minimal?
  • Are return values useful to downstream callers?
  • Are validation and mutation separated when possible?
  • Is the error behavior consistent across the library?
  • Is the API stable enough to survive future protocol changes?
  • Would another developer understand the function without reading the implementation?

If the answer to any of these is “no,” the API likely needs refinement.

Conclusion

A well-designed Solidity library API is concise, explicit, and hard to misuse. The best libraries reduce repetition without hiding important behavior. They make state changes visible, keep inputs narrow, and return values that help the caller make correct decisions.

When you treat library design as part of protocol architecture rather than just code reuse, your contracts become easier to audit, easier to test, and much easier to evolve safely.

Learn more with useful resources