Aggregates
A consistency boundary drawn around state that must change together. Powerful and easy to over-apply — most objects are not aggregates and should not be treated as one.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Which pieces of state must be consistent with each other at every instant, and which are merely related?
An order's total must never exceed the customer's approved credit limit. Two lines added concurrently by two support agents currently produce an order over the limit, and nothing notices until the invoice is rejected.
Every domain object gets a repository and can be loaded and saved independently. An order line is a thing, so it has its own repository, and a support agent adds a line by loading the line collection and saving one row.
The invariant spans several rows, and every write path checks it against a snapshot taken before the other agent's write. Both checks pass, both writes land, and the order is over the limit (Concurrency Anomalies in Database Engineering is the mechanism).
- The invariant spans several rows, and every write path checks it against a snapshot taken before the other agent's write. Both checks pass, both writes land, and the order is over the limit (Concurrency Anomalies in Database Engineering is the mechanism).
- The check gets copied into each write path — add line, change quantity, apply discount, import from CSV — and the fifth path added next quarter forgets it. The rule is now four-fifths enforced, which is indistinguishable from not enforced.
- Nothing declares what "an order" is for consistency purposes, so a new engineer cannot tell whether the discount row is inside the invariant or outside it, and picks by guessing.
- When the rule changes to "excluding cancelled lines", the change has to be found in every path and applied identically, which is the shape of a bug that survives review (Shotgun Surgery).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The database is a single PostgreSQL instance with real transactions available.
- Orders can have up to two thousand lines for wholesale customers, so loading the whole order for every operation has a measurable cost.
- The credit limit lives in a finance system that is updated nightly, so it is not authoritative in real time.
- The sum of an order's line totals never exceeds the credit limit recorded on that order.
- An order line never exists without an order.
- Two concurrent modifications never both succeed if together they would break the first invariant.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- One object owns the invariant that spans several pieces of state, and every change to any of them goes through it (The Aggregate Root).
- The boundary owns transaction scope: everything inside is saved together or not at all.
- Things outside the boundary are referred to by identity only, and are somebody else's consistency problem.
- Nothing owns "keeping two aggregates consistent instantly" — that responsibility is deliberately not taken, and the alternative is eventual consistency with a compensating action.
- The boundary is drawn by the invariant, not by the object graph. State that must be consistent at every instant goes inside; state that may lag goes outside (Consistency Boundaries).
- Everything inside is loaded and saved as one unit, which is exactly why the boundary should be as small as the invariant allows.
- The customer is outside the order boundary even though the invariant mentions the credit limit — the limit is copied onto the order at creation, so the order can enforce its rule without loading the customer.
The boundary is drawn by the invariant
Everything in the diagram below is related to an order. Only some of it must be consistent with an order at every instant, and that difference — not the arrows, not the foreign keys — decides where the line goes.
Note what is deliberately outside. The customer is referenced by id; the credit limit is a copy taken at order creation. Both choices exist so the invariant can be checked without loading anything else, and both have consequences that had to be agreed with the business rather than chosen by an engineer.
- Inside: lines and discounts, because the total depends on both and the total is what the rule is about.
- Outside: the shipment, because no rule requires an order and its shipment to be consistent within one transaction.
- Outside but copied: the credit limit, because copying it makes the invariant checkable locally — at the price of staleness the business has to accept explicitly.
- The test for each item is one sentence: "if these two disagree for one second, is that a bug?" If no, it is outside.
What a boundary buys, and what it costs on the change after
The aggregate is often argued for on correctness alone, which understates the cost. Price a real change under both designs and the trade becomes visible: the boundary makes changes to the rule cheap and makes one specific class of future requirement structurally impossible.
The last row of the cost note is the honest part. A boundary is a commitment about what will never need to be instantly consistent with what, and that commitment is expensive to revise.
Cancelled lines no longer count toward the limit, and a refunded amount frees up limit immediately.
Seven edits of the same logic, and the two paths added most recently already differ subtly from the original. Under concurrency none of them is correct anyway, because each checks against a stale read.
One method changes. Every handler already calls the root, so there is no path that can miss the new rule, and the concurrency test still passes because the version check is on the root.
How big should it be?
Aggregate size is the whole decision, and there is no default answer. The useful framing is that the boundary is simultaneously a transaction, a lock and a load, so every object you pull inside makes all three larger for every operation, not only for the ones the invariant concerns.
The option most teams never consider is the first one below. A great deal of state has no cross-object invariant at all, and for that state the correct aggregate is a single object — or none.
Which state must be consistent with which, at every instant, and how often is it written concurrently?
when No rule spans them. A blog post and its tags; a user and their notification preferences.
cost Nothing. This is the right answer far more often than the literature suggests, and choosing it costs only the discomfort of not having applied a pattern.
when The invariant is within a single object — a status transition, a field range.
cost A version column and optimistic concurrency. Cheap, and it covers most real invariants (Enforcing Invariants).
when Order and its lines, where the total is constrained. The recommended shape.
cost Whole-aggregate load and save, and contention between concurrent edits to any part of it.
when A genuinely inseparable invariant — a double-entry journal where debits must equal credits across all entries.
cost Serious contention and load cost on every operation. Justified only when the invariant is a legal or financial requirement that cannot be eventual.
when The rule spans aggregates but may lag — total exposure across orders, checked by a process that flags or compensates.
cost A window in which the rule is violated, plus a compensating action and the operational work of running it (Eventual Consistency: If Updates Stop, Replicas Converge in Distributed Systems has the theory).
How to build it
Most important first.
- Start from the invariant and ask which state it reads. That set, and nothing more, is a candidate aggregate.
- Keep it small. A large aggregate is a large lock and a large load, and both costs are paid on every operation while the invariant is only relevant to some of them.
- Reference other aggregates by id, never by object. An order holding a
Customerobject invites someone to change the customer through the order, which quietly makes the boundary meaningless. - Copy what you need across the boundary. The credit limit is snapshotted onto the order — this is deliberate denormalization to make the invariant local, and the staleness it introduces must be an accepted business rule, not an accident (Denormalization on Purpose).
- Save one aggregate per transaction. Two aggregates in one transaction is a signal the boundary is in the wrong place, or that the second change should be eventual (One Transaction or Two in Backend has the tactics).
- Do not do this for state with no cross-object invariant. A blog post and its tags have no rule spanning them; making them an aggregate buys a lock and costs a load.
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Before: "the limit excludes cancelled lines" costs an edit in every write path, plus finding them. The cost scales with the number of ways an order can be modified, which only grows.
- After: one method on the root. Every path already goes through it, so there is nowhere else to look.
- The next change that is now expensive: any requirement that needs a piece of state to be consistent across two aggregates — "a customer's total exposure across all open orders must not exceed the limit" — cannot be satisfied inside this design. It needs either a bigger aggregate, with the contention that implies, or an eventual check with a compensating action. That is a real and permanent constraint the boundary imposes.
- And one that stays cheap: adding a new kind of order line. It is inside the boundary, so the invariant applies to it automatically, with no new enforcement code.
- The boundary buys a guaranteed invariant and pays in contention and load size. Both costs are proportional to the boundary, which is the argument for keeping it small.
- Referencing other aggregates by id makes reads more work: a screen showing an order with customer details now needs two loads or a dedicated read model (CQRS in Architecture is the full version).
- Snapshotting the credit limit onto the order means a limit reduction does not apply to existing orders. That may be exactly right or exactly wrong, and it is a business decision an engineer must not make silently.
What can go wrong
- The aggregate is drawn too large — customer, all their orders, all lines — and every operation loads megabytes and contends on one row. This is the most common way aggregates fail, and it fails at exactly the moment of success, under load.
- The aggregate is drawn too small, so the invariant spans two of them and is enforced by a service that loads both, which is the original problem with more ceremony.
- Every entity is made an aggregate root with a repository, so the codebase has forty boundaries, none of which corresponds to an invariant, and the concept stops carrying information (Over-Decomposition).
- The mitigation fails too: optimistic concurrency on the root protects the invariant but produces conflict errors on unrelated concurrent edits — two agents editing two different lines now collide, and the business experiences the fix as a regression.
- Code outside the aggregate depends on it only through its root, which is the point of the boundary (The Aggregate Root).
- The aggregate depends on the value objects it uses and on nothing else — no repositories, no services, no clock unless the clock is injected (Time as a Dependency).
- Persistence depends on the aggregate boundary for its transaction scope, which makes the boundary a decision with an operational consequence and not just a modelling one.
- "Every entity needs to be in an aggregate." Most state has no cross-object invariant and needs no boundary. If you cannot name the rule that spans the objects, there is no aggregate — there is a list (YAGNI, With Its Bill Attached).
- "Aggregates are about object graphs." They are about consistency. Two objects that reference each other constantly may belong to different aggregates; two that never touch may belong to the same one if a rule spans them.
- "Bigger aggregates are safer." Bigger aggregates make more things consistent and make every operation slower and more contended. Safety at the cost of throughput is a trade, not a free win.
- "Aggregates require event sourcing / CQRS / a repository per root." They require a transaction boundary. Everything else is a separate decision with its own cost (When Domain-Driven Design Does Not Pay).
- shotgun-surgery
- god-object
Testing it, and how it ages
- Test the invariant on the root with no database: add lines until the limit is exceeded and assert the rejection.
- Test the concurrency case explicitly — two loads, two modifications, two saves — and assert that exactly one succeeds. This is the test that justifies the aggregate, and it is usually missing (Concurrency by Design).
- A structural test that no code outside the aggregate imports its internal types, because that is how the boundary erodes (Internal Module Contracts).
- Load and save the largest realistic aggregate in a performance test, because aggregate size is a design decision with a latency consequence.
- Aggregates grow as invariants are added and should be resisted, because every addition raises the cost of every operation on the aggregate.
- The usual healthy evolution is splitting: an order aggregate that acquired fulfilment state becomes an order and a fulfilment, related by id and consistent eventually.
- The forcing function is almost always contention. When one aggregate becomes a hot row, the boundary is wrong for the load the system now has, and no amount of tuning fixes it (Shared-State Coupling).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThat an invariant spanning several pieces of state needs a single place to be enforced, and a transaction wide enough to cover them, follows from the invariant itself and holds in any language or store that has transactions.
- DOMAIN-SPECIFICAggregates pay where invariants genuinely span objects — orders and lines, accounts and entries, policies and coverages. In a content system, an analytics pipeline or a settings screen there is usually no such invariant, and the boundary is cost with no return.
- SCALE-SPECIFICAt low write concurrency, a service that loads both objects and checks the rule works fine and is simpler. The aggregate earns its place when concurrent writes to the same state are frequent enough that lost-update anomalies are certain rather than theoretical, which for most systems is a specific handful of objects rather than all of them.
- CONTESTEDThe strongest opposing view: the database already has the consistency mechanism, and expressing the invariant as a constraint or a serializable transaction is simpler, faster and harder to bypass than an in-memory boundary that only code going through the ORM respects. Practitioners in data-centric systems point out — correctly — that an aggregate is unenforceable against a bulk
UPDATE, while aCHECKconstraint is not (Database Constraints).
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — once the two sides of an invariant live in different services, the aggregate boundary becomes a distributed transaction question and the honest answers are a saga or a redesign of the invariant.