Aggregates & Consistency
An aggregate is a cluster of objects guarded by a root that enforces invariants - the transactional consistency boundary, and why you reference other aggregates by id.
On this page
Entities and value objects don't float around loose - they cluster into aggregates, and this is the DDD concept with the most practical bite. An aggregate is a group of objects treated as a single unit for data changes, guarded by one entity - the aggregate root - that enforces the group's invariants. Get aggregate boundaries right and consistency becomes tractable; get them wrong and you either lock too much or corrupt your data.
The consistency boundary
An invariant is a rule that must always hold. In ledger-legacy: "an account's balance equals the sum of
its postings," or "an order's total equals the sum of its line items." Someone must guarantee these rules hold
after every change - and that someone is the aggregate root.
An aggregate is a cluster of entities and value objects that change together and share invariants. Its root is the one entity that outside code is allowed to touch; everything inside is reached through the root:
class Account { // the AGGREGATE ROOT
private final AccountId id;
private Money balance;
private final List<Posting> postings; // internal entities - not exposed directly
// the ONLY way to change the aggregate - the root enforces the invariant
public void post(Money amount, String memo) {
Posting p = new Posting(amount, memo, clock.now());
this.postings.add(p);
this.balance = this.balance.add(amount); // invariant maintained atomically
if (balance.isNegative() && !overdraftAllowed)
throw new OverdraftException(); // rule enforced HERE, always
}
public List<Posting> postings() { return List.copyOf(postings); } // read-only view out
}Outside code cannot add a Posting directly - there's no account.getPostings().add(...) that bypasses the
balance update. Every mutation goes through account.post(...), so the invariant "balance = sum of postings" can
never be violated. The aggregate is the boundary of transactional consistency: one aggregate, one
transaction, invariants always intact within it.
Rules of aggregate design
A few hard-won rules make aggregates work:
- Reference other aggregates by id, not by object. An
Orderholds aCustomerId, not aCustomerobject. This keeps aggregates small, decoupled, and independently loadable - and stops one transaction from accidentally dragging in half the object graph. - One transaction modifies one aggregate. If a single operation must change two aggregates, that's a signal to use a domain event and eventual consistency between them (next lesson) rather than one giant transaction.
- Keep aggregates small. A big aggregate (an account holding all its transactions ever) means loading and locking huge object graphs. Prefer small aggregates; if postings grow unbounded, the aggregate boundary may be wrong.
Why "small" and "by id" matter
These rules are really about contention and coupling. Every change to an aggregate typically locks it
(optimistically or otherwise). A giant aggregate is a giant lock - a bottleneck under load. Referencing other
aggregates by object tempts you to modify them in the same transaction, blurring boundaries until "one
transaction, one aggregate" collapses and you're back to ledger-legacy's everything-touches-everything mess.
Model true invariants, not convenient navigation
The temptation is to make an aggregate big because it's convenient to navigate object-to-object. Resist it. The aggregate boundary should be drawn around what must be transactionally consistent together - the real invariants - not around what's handy to click through. 'I want to load the customer with the order' is a query concern, solved by a read model or a repository join, not a reason to merge two aggregates.
An aggregate is a shopping cart: the cart (root) owns its line items, and the rule 'cart total = sum of item prices' must always hold. You don't reach into someone's cart and edit a line item directly - you go through the cart, which recomputes the total, so the invariant never breaks. And the cart references the customer by their loyalty-card number (an id), not by containing the whole customer record - the cart and the customer are separate carts-worth of consistency. Try to cram the customer, their entire order history, and the warehouse inventory all into one cart, and checkout would have to lock the whole store to add a banana.
In ledger-legacy, an Order has line items and a shipping address, and it belongs to a Customer who has many
orders and a credit limit. A junior models one giant Customer aggregate containing all orders, each with all
line items, and loads the whole thing to add one line item - causing lock contention and slow loads. Redraw the
aggregates and explain how the two now relate.
What is an aggregate root's primary responsibility?
Key takeaways
- An aggregate is a cluster of entities and value objects that change together under shared invariants, guarded by one aggregate root.
- All external changes go through the root, which enforces invariants atomically - internal objects are never modified directly.
- The aggregate is the boundary of transactional consistency: one transaction modifies one aggregate.
- Reference other aggregates by id (not by object) and keep aggregates small, to limit lock contention and coupling.
- Draw boundaries around true transactional invariants, not around navigation convenience; cross-aggregate operations use domain events and eventual consistency.