
Solidity Custom Structs and Nested Data Models: Designing Rich On-Chain State
Why structs matter in advanced Solidity
A struct groups related fields into a single type. Instead of scattering values across many variables, you can represent a domain object explicitly.
For example, an escrow contract might need:
- payer address
- payee address
- amount
- deadline
- release status
Without structs, these values become harder to track and pass around. With structs, the contract can treat them as one logical unit.
Typical use cases
Structs are especially useful for:
- order books and trade records
- governance proposals and votes
- vesting schedules
- lending positions
- NFT metadata snapshots
- multi-step workflows with state transitions
The main advantage is not just organization. Structs also reduce parameter clutter, improve readability, and make it easier to reason about invariants.
Defining a struct
A struct is declared at contract scope or inside a library.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Escrow {
struct Payment {
address payer;
address payee;
uint256 amount;
uint64 releaseTime;
bool released;
}
}This Payment type can now be used like any other Solidity type.
Creating and assigning struct values
You can construct structs in several ways:
Payment memory p1 = Payment({
payer: msg.sender,
payee: recipient,
amount: 1 ether,
releaseTime: uint64(block.timestamp + 7 days),
released: false
});Or positionally:
Payment memory p2 = Payment(msg.sender, recipient, 1 ether, uint64(block.timestamp + 7 days), false);The named form is usually safer because it avoids mistakes when field order changes.
Storage, memory, and calldata considerations
Structs behave differently depending on data location.
| Location | Typical use | Notes |
|---|---|---|
storage | persistent contract state | Modifying fields writes to chain state |
memory | temporary in-function data | Cheaper for local computation |
calldata | external function inputs | Read-only and gas-efficient for parameters |
Example: reading from storage, copying to memory
function getPayment(uint256 id) external view returns (Payment memory) {
return payments[id];
}If you only need to inspect a struct, returning a memory copy is fine. If you want to update it, work with a storage reference:
function release(uint256 id) external {
Payment storage payment = payments[id];
require(!payment.released, "Already released");
require(block.timestamp >= payment.releaseTime, "Too early");
payment.released = true;
payable(payment.payee).transfer(payment.amount);
}Using storage here is critical. If you accidentally use memory, changes will not persist.
Structs inside mappings and arrays
The most common advanced pattern is a mapping from an identifier to a struct.
mapping(uint256 => Payment) public payments;
uint256 public nextPaymentId;Creating records
function createPayment(address payee, uint64 releaseTime) external payable returns (uint256) {
require(msg.value > 0, "No value");
uint256 id = nextPaymentId++;
payments[id] = Payment({
payer: msg.sender,
payee: payee,
amount: msg.value,
releaseTime: releaseTime,
released: false
});
return id;
}This pattern works well when each record has a unique ID.
Arrays of structs
Arrays are useful when order matters or when you need enumeration.
Payment[] public paymentList;Appending is straightforward:
paymentList.push(Payment(msg.sender, payee, msg.value, uint64(block.timestamp + 1 days), false));However, arrays are less convenient for lookup by key and can become expensive to iterate over on-chain. For large datasets, prefer mappings plus indexes or pagination.
Nested structs for richer models
You can compose structs from other structs. This is useful when a concept has subcomponents.
struct TokenAmount {
address token;
uint256 amount;
}
struct Order {
address maker;
address taker;
TokenAmount offer;
TokenAmount request;
uint64 deadline;
}Nested structs make the domain model explicit. In this example, offer and request are both token-value pairs, so reusing TokenAmount avoids duplication.
Example: nested configuration
struct FeeConfig {
address recipient;
uint16 bps;
}
struct MarketConfig {
bool enabled;
uint64 minOrderAge;
FeeConfig fee;
}This is cleaner than flattening every field into one large struct, especially when some values naturally belong together.
Practical example: a simple order registry
The following contract shows a realistic pattern for storing and updating structured records.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract OrderRegistry {
struct Asset {
address token;
uint256 amount;
}
struct Order {
address maker;
Asset sell;
Asset buy;
uint64 deadline;
bool filled;
}
uint256 public nextOrderId;
mapping(uint256 => Order) private orders;
event OrderCreated(uint256 indexed orderId, address indexed maker);
event OrderFilled(uint256 indexed orderId, address indexed taker);
function createOrder(
address sellToken,
uint256 sellAmount,
address buyToken,
uint256 buyAmount,
uint64 deadline
) external returns (uint256 orderId) {
require(sellAmount > 0 && buyAmount > 0, "Invalid amounts");
require(deadline > block.timestamp, "Expired");
orderId = nextOrderId++;
orders[orderId] = Order({
maker: msg.sender,
sell: Asset({token: sellToken, amount: sellAmount}),
buy: Asset({token: buyToken, amount: buyAmount}),
deadline: deadline,
filled: false
});
emit OrderCreated(orderId, msg.sender);
}
function fillOrder(uint256 orderId) external {
Order storage order = orders[orderId];
require(order.maker != address(0), "Unknown order");
require(!order.filled, "Already filled");
require(block.timestamp <= order.deadline, "Expired");
order.filled = true;
emit OrderFilled(orderId, msg.sender);
}
function getOrder(uint256 orderId) external view returns (Order memory) {
require(orders[orderId].maker != address(0), "Unknown order");
return orders[orderId];
}
}What this example demonstrates
Assetis reused for both sides of the trade.Ordergroups all trade data into one record.mapping(uint256 => Order)gives constant-time lookup.storageis used for mutation infillOrder.- Events expose state changes for off-chain indexing.
Best practices for struct design
1. Keep structs domain-focused
A struct should represent one concept. If a struct starts mixing unrelated concerns, split it.
Good examples:
VestingScheduleLoanPositionAuctionBid
Poor example:
ContractStatewith dozens of unrelated fields
Large “god structs” are hard to reason about and often signal weak contract boundaries.
2. Prefer named construction
Named fields reduce bugs when the struct changes.
Payment memory p = Payment({
payer: msg.sender,
payee: recipient,
amount: amount,
releaseTime: releaseTime,
released: false
});This is safer than relying on positional order.
3. Use smaller integer types only when justified
You may be tempted to use uint64 or uint128 everywhere. That can help packing, but only when the value range is truly bounded.
For example:
- timestamps can often fit in
uint64 - basis points fit in
uint16 - counters usually need
uint256
Choose types based on semantics first, optimization second.
4. Avoid unbounded iteration over struct arrays
If you store many structs in an array, do not assume you can safely loop through all of them on-chain. Gas costs grow with array size.
Instead:
- index by ID in a mapping
- maintain a small active set
- paginate reads off-chain
- use events for historical analysis
5. Be careful with nested dynamic data
Structs can contain dynamic arrays and strings, but that increases complexity and gas cost.
struct Proposal {
address proposer;
string description;
bytes[] evidence;
}This is valid, but dynamic members are expensive to store and copy. Use them only when the flexibility is worth the cost.
Common pitfalls
Returning storage references from public APIs
External callers cannot receive storage references. Public and external functions should return copies, usually in memory.
Forgetting default values
Uninitialized struct fields use default values:
address(0)0false- empty string or bytes
This is useful for sentinel checks, but it can also hide bugs if you forget to initialize a record.
Accidentally copying large structs
Passing large structs by value can be expensive. For internal functions, use storage when mutating and calldata when accepting external input that does not need modification.
function validateOrder(Order calldata order) external pure returns (bool) {
return order.deadline > 0;
}Overexposing internal state
If a struct contains sensitive fields, avoid making the mapping public unless the autogenerated getter is acceptable. Public getters for structs can be awkward and may reveal more than intended.
When to use structs versus separate variables
| Approach | Best for | Trade-off |
|---|---|---|
| Separate variables | Very small, fixed state | Harder to maintain as complexity grows |
| Structs | Related fields forming one entity | Slightly more complex syntax |
| Nested structs | Rich domain models | More careful design required |
A good rule: if two or more variables are always read, written, and validated together, they probably belong in a struct.
Structs and contract upgrade design
When designing upgradeable systems or long-lived protocols, struct shape matters. Changing field order or removing fields can break storage expectations if the struct is stored in contract state.
To reduce risk:
- append new fields rather than reordering existing ones
- avoid changing field types in deployed storage layouts
- document each field’s purpose clearly
- keep structs minimal and stable
Even in non-upgradeable contracts, stable struct design improves auditability and reduces integration bugs.
Conclusion
Structs are one of Solidity’s most practical advanced features. They let you model real-world entities directly, reduce parameter sprawl, and organize complex state into coherent units. When combined with mappings, arrays, and nested composition, they become the foundation for expressive smart contract architecture.
The key is to treat structs as domain models, not just containers. Keep them focused, initialize them explicitly, and choose the right data location for each use case. Done well, structs make contracts easier to build, review, and maintain.
