الدرس 2 من 2
Aggregates as Consistency Boundaries
The tactical patterns only pay off inside a correct context. The most misused of them is the aggregate, which is a transaction boundary rather than an object graph.
Inside a bounded context, domain-driven design offers a small vocabulary for structuring the model: entities, value objects, aggregates, repositories and domain services. They are useful, and they are worth far less than the strategic patterns. A team applying tactical patterns inside wrong boundaries produces beautifully modelled objects in the wrong places.
| Pattern | What it is | The question it answers |
|---|---|---|
| Entity | Something with identity that persists through change | Is this the same thing it was yesterday, even if every field changed? |
| Value object | Something defined entirely by its values, with no identity | Would two of these with identical values be interchangeable? |
| Aggregate | A cluster of objects treated as one unit for changes, with a root | What must be consistent at every commit? |
| Repository | A collection-like interface for loading and saving aggregates | How does the domain get its objects without knowing about storage? |
| Domain service | A domain operation that belongs to no single entity | Where does a rule live when it involves several aggregates? |
// Order is an aggregate root. Its invariant: line totals must equal the
// order total, and a confirmed order may not be modified.
export class Order {
private constructor(
readonly id: OrderId,
private readonly lines: readonly OrderLine[],
private readonly status: OrderStatus,
) {}
addLine(line: OrderLine): Order {
if (this.status !== OrderStatus.Draft) {
throw new DomainError('A confirmed order cannot be modified');
}
return new Order(this.id, [...this.lines, line], this.status);
}
// Customer is a *different* aggregate. We hold an identifier, never the
// object — otherwise one transaction would have to lock both.
readonly customerId: CustomerId;
}
// A rule spanning two aggregates ("a customer may hold at most five open
// orders") is checked before the command and enforced eventually, or moved
// into whichever aggregate genuinely owns the invariant. It does not justify
// merging Customer and Order into one aggregate.Two ways to enforce a rule that spans aggregates
Enlarge the aggregate
Put both entities inside one aggregate so the invariant can be checked in one transaction.
- The rule is enforced immediately and cannot be violated
- Simple to reason about: one lock, one commit
- Loading cost grows with the whole cluster
- Contention: unrelated changes now conflict
- Aggregates tend to grow until they cover a whole context
Choose when: The invariant is genuinely non-negotiable at every instant and the cluster is small and bounded — an order and its lines, not a customer and their history.
Check before, correct after
Validate the rule when handling the command, and detect and correct violations asynchronously if a race occurs.
- Aggregates stay small, so loading and contention stay low
- Scales to rules spanning many entities
- Matches how the business usually handles it anyway
- A brief window in which the rule can be violated
- Detection and compensation logic to build and monitor
Choose when: The rule tolerates a short violation window — most business rules do, including credit limits and quotas, which businesses routinely resolve after the fact.
In practice
Shrinking an aggregate that was causing outages
A ticketing system modelled `Event` as an aggregate containing all of its ticket types, seat allocations and holds — up to 40,000 objects for a large concert. During on-sales, checkout failed with concurrency conflicts on roughly one in six attempts.
Constraints
- Up to 40,000 seats per event
- Peak of 2,000 concurrent purchase attempts
- The genuine invariant is that a seat cannot be sold twice
- A seat hold expires after ten minutes
Decision
Make each seat allocation its own aggregate with the event referenced by identifier. The double-sale invariant is enforced by a unique constraint on the seat plus a held-or-sold state on that single small aggregate.
Why
The invariant that mattered was per seat, not per event, so the aggregate had been drawn around a conceptual grouping rather than a consistency requirement. Two people buying different seats at the same concert have no reason to conflict, and with the old design they contended on the same root and both retried.
What it cost
Questions such as "how many seats remain?" now need a query across many aggregates rather than a field on one, so the team added a counter maintained by a projection — accepting that the displayed remaining count can be a second or two stale. They judged a slightly stale count far cheaper than one in six checkouts failing, and noted that the stale count is safe precisely because the real invariant is enforced at the seat.
Asked to "do domain-driven design" on a new project
As a developer
Starts with the tactical patterns, because they are concrete and reachable from code: entities, value objects, repositories, an aggregate per main table, and a rich model instead of anaemic classes.
As an architect
Starts with the language and the boundaries, because the tactical patterns applied inside wrong contexts produce elegant models that still cannot change independently. Two days of conversation with domain experts about what words mean will reshape a system more than a month of modelling — and if the organisation cannot supply a domain expert to talk to, that is worth knowing before the project starts, because DDD without access to the domain is just object modelling with unfamiliar vocabulary.
A rule says a customer may hold at most five open orders. Should `Customer` and `Order` be one aggregate?RevealHide
Almost certainly not. Merging them means loading a customer with all of their orders on every change and serialising unrelated order edits behind one root, which is a large permanent cost to enforce a rule that tolerates a brief violation. The proportionate design checks the count when handling the command — which catches essentially every real case, since a customer is rarely placing two orders in the same millisecond — and detects and resolves the rare race afterwards. It is worth noticing that businesses already work this way: a credit limit is checked at purchase and reconciled later, and nobody expects instantaneous global enforcement. The aggregate boundary should follow invariants that genuinely cannot tolerate any window, and there are fewer of those than teams assume.
Key takeaways
- Tactical patterns applied inside wrong boundaries produce well-modelled objects in the wrong places.
- An aggregate is a consistency boundary defined by invariants at commit time, not a conceptual grouping.
- Keep aggregates small and relate them by identifier; large aggregates cause loading cost and contention.
- Rules spanning aggregates are usually best checked before and corrected after, as businesses already do.
- A bounded context is the strongest available candidate for a service boundary — the bridge into Chapter 4.