open atlas
↑ Back to track
Architecture Patterns ARCH · 06 · 03

Aggregates and invariants

An aggregate is a transactional consistency boundary. The aggregate root is the only external entry point. One transaction modifies one aggregate. Size aggregates by the invariant they enforce, not by the data graph — small is the right default.

ARCH Senior ◷ 24 min
Level
FoundationsJuniorMiddleSenior

The B2B order platform’s Sales context had a Quote that contained LineItem objects. A LineItem had a NegotiatedPrice. A Quote had a business rule: the total value of all line items must not exceed the customer’s credit limit at the time of submission. The rule seemed simple. Then the team hit a concurrency problem. Two account executives were editing the same Quote simultaneously — one adding a high-value line item, one removing a different one. Both edits passed the credit-limit check in isolation because each read the Quote before the other’s write landed. After both writes committed, the total exceeded the limit. The invariant — the credit-limit rule — had been violated despite both writes being individually valid. The team’s first fix was to add an optimistic lock on the Quote row. That worked for Quote-level conflicts, but then another problem surfaced: a developer had written a batch job that updated LineItem prices directly, bypassing the Quote entirely. The batch job had no knowledge of the credit-limit rule because it was checking only the LineItem, not the Quote that owned it. Two separate transactions, two separate invariant checks, same corruption. The team had accidentally split what should have been one consistency unit into two. The aggregate pattern is the answer to this class of problem. An aggregate defines the transactional consistency boundary: a cluster of domain objects treated as one unit for the purposes of data changes. The aggregate root is the single entry point. Every mutation goes through it. The invariants it enforces are guaranteed to hold at the end of every transaction.

What an aggregate enforces

An aggregate is not a grouping of related data. It is a grouping of data around a consistency invariant — a business rule that must hold true across multiple objects after every operation.

The Quote aggregate enforces: “the total negotiated value of all line items must not exceed the customer’s credit limit at submission.” This rule spans the Quote and all its LineItem objects. The Quote cannot delegate this check to LineItem because LineItem does not know about the total. No individual LineItem can violate the rule — only the collection as a whole can.

This is the defining criterion for an aggregate: what is the smallest set of objects I must load and lock together to enforce a specific business invariant? That set is the aggregate. The object at the root of that set, the one that enforces the invariant, is the aggregate root.

The aggregate root is the only entry point for external code. No object outside the aggregate holds a direct reference to an internal LineItem. External code calls quote.addLineItem(...) or quote.removeLineItem(...). The Quote root runs the invariant check. The LineItem objects are internal implementation — they cannot be accessed, modified, or deleted by going around the root.

One transaction modifies one aggregate

This rule is the structural consequence of the consistency boundary. If one transaction modifies two aggregates, you have either:

  1. Put the wrong objects in the same aggregate (the invariant actually spans both, so they should be one aggregate), or
  2. Accepted eventual consistency between the two aggregates (the invariant does not need to hold synchronously across both, so they communicate via events).

The “one transaction, one aggregate” rule is why the batch job in the opening story caused problems. It modified LineItem in one transaction without going through Quote. The Quote’s invariant check never ran. The fix is to remove direct LineItem writes entirely — all mutations go through Quote.addLineItem() or Quote.updateLineItemPrice(), which enforces the invariant after every change.

Why this works

Why is this rule so strict? Because “one transaction modifies one aggregate” is what makes the aggregate boundary enforceable. If two aggregates can be modified in the same transaction, then any invariant that spans those two aggregates must be checked in every transaction that touches either. You can no longer know which transactions need the check without analyzing all of them. The aggregate boundary becomes meaningless. Once you commit to one transaction per aggregate, the aggregate root becomes the only place invariant logic needs to live — and you can reason about it in isolation.

Referencing other aggregates by ID

When one aggregate needs to reference another, it does so by identifier only — not by object reference. The Quote aggregate in Sales might need to reference the Customer it belongs to. But Customer is a separate aggregate (it has its own invariants around credit history and account status). The Quote aggregate holds a customerId — a value object wrapping the customer’s identifier — not a direct reference to the Customer object.

This has three consequences:

  1. No cascade loading: loading a Quote does not automatically load the Customer graph. Each aggregate is loaded independently.
  2. No cascade mutation: the aggregate root can only enforce invariants over the objects it owns. It cannot mutate a Customer through a reference — it would have to call Customer through its own root.
  3. Explicit cross-aggregate consistency: if an operation must change both a Quote and a Customer, those changes happen in separate transactions, coordinated through domain events (a QuoteSubmitted event may trigger a process that updates Customer.openQuoteCount). This is eventual consistency by design.

Sizing aggregates: by invariant, not by data graph

The most common aggregate design mistake is making aggregates too large. Teams look at the object graph — Quote has LineItems, LineItems have Products, Products have PricingRules, PricingRules have Tiers — and bundle everything into one massive aggregate because “it all belongs together.”

The correct sizing question is: what is the invariant that must hold transactionally? If the invariant is “total line-item value must not exceed the credit limit at submission,” then the aggregate is Quote + LineItems. That is it. Product is a separate aggregate — its own rules (name, description, catalog status) have nothing to do with the Quote’s credit-limit rule. PricingRule is likely a separate aggregate. The Quote holds a productId reference and a NegotiatedPrice value object — it does not own the Product.

Small aggregates have two critical advantages:

Lock contention: a large aggregate is locked for the entire duration of a transaction. If your Order aggregate contains every line item, every shipment, every payment, and every customer interaction log, then every order operation — adding a line item, updating a shipping address, logging a customer note — locks the entire order. In a busy system, this creates serialization bottlenecks. Small aggregates lock only what needs to be locked for the specific invariant.

Conceptual clarity: a large aggregate that bundles unrelated things is a signal that the invariants have not been clearly identified. “Everything about an Order” is not an invariant. “The submitted value of a Quote must not exceed the customer’s credit limit” is an invariant. Aggregates sized by invariant make the business rules visible in the code.

The default for aggregate size is: as small as the invariant allows. Start with the smallest aggregate that enforces the invariant. Expand only when a real invariant demands it — not when the object graph suggests it.

Quiz

A developer proposes: 'The Order aggregate should contain OrderLineItems, Shipments, and Payments, because all three are part of an order.' A DDD-experienced reviewer objects. What is the reviewer's core argument?

Quiz

The Quote aggregate enforces: 'total line-item value must not exceed the customer's credit limit at submission.' The team wants to update the customer's credit limit when a Quote is submitted (to reserve the credit). They propose: inside `quote.submit()`, call `customer.reserveCredit(quote.total())` directly. What is wrong with this design?

Quiz

A team has an `Order` aggregate that contains every `LineItem`, and they are experiencing lock contention: order processing is slow because two operations that touch different line items on the same order cannot run concurrently. A developer proposes: 'Make each LineItem its own aggregate.' What is the trade-off the team must evaluate before doing this?

Recall before you leave
  1. 01
    What is the aggregate root, and why is it the only external entry point?
  2. 02
    Why does 'one transaction modifies one aggregate' follow from the aggregate boundary?
  3. 03
    What is the correct criterion for sizing an aggregate, and why does 'size by data graph' fail?
Recap

The opening story’s concurrency bug came from splitting one consistency unit — the Quote with its credit-limit invariant — into two separately mutable objects. The fix was not a new lock; it was recognizing that Quote and its LineItems form one aggregate, with Quote as the root that enforces the credit-limit rule across all line items in every transaction.

An aggregate is a transactional consistency boundary. The root is the only entry point. All mutations flow through it so the invariant check always runs. One transaction modifies one aggregate. Other aggregates are referenced by ID — no direct object references cross aggregate boundaries.

Sizing is the critical skill. The question is not “what is related?” but “what invariant must hold transactionally?” Start with the smallest aggregate that enforces the invariant. Resist the pull to include everything related — large aggregates serialize unrelated operations and load unnecessary data. Cross-aggregate consistency is resolved through domain events and eventual consistency, which is almost always acceptable because very few business rules require simultaneous atomic changes across multiple domain objects.

The next lesson examines how multiple bounded contexts relate to each other — the strategic integration patterns that let independent contexts communicate without leaking their models into each other.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.