Aggregates¶
An aggregate is a cluster of entities and value objects treated as one unit for changes. One entity is the aggregate root, and it is the only member outside code may reference.
An aggregate is not a "group of related things". It is a consistency boundary: the set of objects that must change together, in one transaction, for the domain rules to stay true.
graph TD
subgraph Order aggregate
O[Order, aggregate root] --> L1[OrderLine]
O --> L2[OrderLine]
O --> A[ShippingAddress, value object]
end
EXT[Outside code] --> O
EXT -.->|forbidden| L1
The four rules¶
- Reference the root only. Outside code never holds an
OrderLine. If it needs to change one, it asks theOrder. - One transaction, one aggregate. Needing to change two atomically means either the boundary is wrong, or the second change should be eventually consistent through a domain event.
- Reference other aggregates by identity.
Orderholds acustomer_id, never aCustomerobject. - Invariants hold at the boundary. Every rule spanning members is enforced by the root, and the aggregate is valid whenever a transaction commits.
class Order: # aggregate root
def add_line(self, sku: Sku, qty: int, price: Money) -> None:
if self.status is not Status.DRAFT:
raise OrderAlreadyPlaced(self.id) # rule 4
if self.total() + price * qty > self.credit_limit:
raise CreditLimitExceeded(self.id)
self._lines.append(OrderLine(sku, qty, price))
def lines(self) -> tuple[OrderLine, ...]:
return tuple(self._lines) # rule 1: no mutable handle escapes
Returning the internal list directly would hand a caller the ability to bypass every check above, which is the most common way rule 1 is broken by accident.
Finding the boundary¶
Ask, for each pair of objects: if these two are briefly out of step, is the business broken?
- An order and its lines — yes, a total computed from half-saved lines is wrong. One aggregate.
- An order and the customer's loyalty points — no, awarding points a second later is fine. Two aggregates, linked by an event.
That question, asked out loud with a domain expert, decides more than any diagram.
Keep them small¶
The classic beginner error is a Customer aggregate owning every order, invoice and
ticket.
| Symptom | Cause |
|---|---|
| Loading one customer pulls thousands of rows | Aggregate too large |
| Two users editing unrelated orders collide | Lock contention on a shared root |
| Every transaction touches the same row | The whole system funnels through one aggregate |
Prefer many small aggregates linked by identity. A useful default is a root plus the handful of objects that genuinely cannot be stale relative to it, and single-entity aggregates are common and fine.
Concurrency¶
Because the aggregate is the transaction boundary, it is also the locking unit.
Optimistic concurrency with a version number on the root is the standard approach: load
at version 7, save with WHERE version = 7, and if zero rows update, someone else won
and the operation retries. Small aggregates make those collisions rare.
Domain events¶
When two aggregates must react to each other, the second change happens afterwards, in its own transaction.
sequenceDiagram
participant O as Order aggregate
participant B as Event bus
participant I as Inventory aggregate
O->>B: OrderPlaced
B->>I: OrderPlaced
I->>I: reserve stock (own transaction)
Events are immutable facts named in the past tense: OrderPlaced, PaymentFailed. The
trade is deliberate: the two aggregates become eventually consistent, which is the
price of keeping each transaction to one aggregate. This is also the seam to
event-driven architecture and to CQRS, where reads
are served by a separate model.
If eventual consistency is genuinely unacceptable for a rule, that rule is telling you the two things belong in one aggregate after all.
Aggregates and services¶
An aggregate is loaded and saved as a whole through a repository, one repository per root. Logic spanning several aggregates goes in a domain service, not into one of the roots.
Check Your Understanding¶
What defines an aggregate boundary?
Order needs to reference the customer. What should it hold?
A Customer aggregate owns every order and invoice for that customer. What goes wrong?