DDD tactical design: aggregates as consistency boundaries

What it is

The building blocks inside one bounded context, and the one that matters is the aggregate, because an aggregate is a transaction boundary rather than an object graph.

ENTITY          Has identity that persists through change.
                Two Orders with identical fields are
                different orders.

VALUE OBJECT    Defined entirely by its attributes, and
                immutable. Two Money(40, "USD") are the same
                thing. Replaced rather than mutated.

AGGREGATE       A cluster of entities and value objects
                with ONE entity as its root, treated as a
                single unit for consistency.

AGGREGATE ROOT  The only entity outside code may hold a
                reference to. Everything inside is reached
                through it.

INVARIANT       A rule that must be true at every
                transaction boundary. THE aggregate is drawn
                around the invariants.

Commonly confused with "a class with its children". The aggregate boundary is derived from the invariants, not from the object graph. An Order and its LineItems are one aggregate because the total must equal the sum of the lines; an Order and its Customer are not, even though the object graph connects them, because no rule spans both.

Also commonly confused with the whole of DDD. This is the tactical half, and Evans has said the emphasis on it was the main misreading of his book. The strategic half determines service and team boundaries. See DDD strategic design.

The problem it solves

Without an explicit consistency boundary, every operation loads whatever it needs, and invariants become unenforceable because no single place is responsible for them.

THE FAILURE

  Code in twelve places loads an Order.
  Three of them load it WITHOUT its line items, because
  they only need the status.
  One of those three sets the status to SHIPPED.

  The rule "an order cannot ship if any line item is
  backordered" lives in a service class that the other
  three paths do not call.

  -> The invariant is violated, silently, by a code path
     that was never reviewed against it, and the bug is
     found by a customer.

The aggregate's answer: if the only way to load an Order is whole, through its root, then order.ship() can enforce the rule and no path can bypass it. That is the entire value, and it is why "load it whole" is a constraint rather than a performance mistake.

Mechanics

Drawing the boundary from the invariants

The procedure, which is mechanical once you accept the premise:

1. List the invariants. Rules that must be true at every
   commit, stated as sentences.

     "An order's total equals the sum of its line items."
     "An order cannot ship with a backordered line."
     "A customer's credit limit must not be exceeded by
      their outstanding orders."

2. For each, note which entities it spans.

     total = sum(lines)          -> Order, LineItem
     cannot ship if backordered  -> Order, LineItem
     credit limit                -> Customer, MANY Orders

3. Invariants that span entities force them into one
   aggregate. Invariants that span AGGREGATES cannot be
   enforced transactionally and must become eventual.

     Order + LineItems           -> one aggregate
     Customer + all their Orders -> NOT one aggregate.
                                    That would make every
                                    order write lock the
                                    customer.

Step 3 is the decision. The credit-limit rule genuinely spans many orders, and enforcing it transactionally means one aggregate containing a customer and every order they have ever placed, which is a lock on every write. So it becomes an eventual check with a compensating action, and being explicit that this is a trade rather than an oversight is the signal.

The four rules, and what each costs

Vaughn Vernon's rules of aggregate design, with the reasoning:

1. PROTECT TRUE INVARIANTS INSIDE ONE BOUNDARY.
   The aggregate exists for this. If there is no invariant,
   there is no reason for the aggregate.

2. DESIGN SMALL AGGREGATES.
   Large aggregates mean loading a lot to change a little,
   and they mean lock contention. An Order with 500 line
   items loaded to change a status is both.

3. REFERENCE OTHER AGGREGATES BY IDENTITY, NOT BY OBJECT.
   order.customerId, not order.customer.
   This is the rule that keeps aggregates small, and it is
   the one people break first because an object reference
   is more convenient.

4. USE EVENTUAL CONSISTENCY BETWEEN AGGREGATES.
   One transaction changes ONE aggregate. Cross-aggregate
   effects happen via domain events.

Rule 3 is the load-bearing one, and breaking it is how aggregates become the whole object graph: order.customer.orders is a reference chain that loads everything, and once one exists nothing constrains the boundary any more.

# The boundary, expressed
class Order:                       # aggregate root
    def __init__(self, id: OrderId, customer_id: CustomerId):
        self.id = id
        self.customer_id = customer_id      # IDENTITY, not the object
        self._lines: list[LineItem] = []     # INSIDE the boundary
        self.status = OrderStatus.DRAFT

    def add_line(self, sku: Sku, qty: int, price: Money) -> None:
        if self.status is not OrderStatus.DRAFT:
            raise OrderNotEditable(self.id)
        self._lines.append(LineItem(sku, qty, price))
        # The invariant is maintained HERE, at the only place
        # that can change it.
        assert self.total() == sum(l.subtotal() for l in self._lines)

    def ship(self) -> ShipmentRequested:
        # The rule that motivated the boundary. Because the
        # ONLY way to have an Order is to load it whole, this
        # cannot be bypassed.
        if any(l.backordered for l in self._lines):
            raise CannotShipBackorderedItems(self.id)
        self.status = OrderStatus.SHIPPED
        # Cross-aggregate effects leave as EVENTS, not as
        # direct calls into another aggregate.
        return ShipmentRequested(self.id, self._lines)

Note what is absent: no repository call, no service lookup, no reference to Customer. An aggregate that reaches out to load something has stopped being a consistency boundary.

Value objects, which are under-used

Money(40, "USD")  rather than  amount: float, currency: str

WHAT YOU GET
  Immutability, so it cannot be mutated by a caller
  holding a reference.
  Validated construction: a Money cannot exist with a
  negative amount if the constructor forbids it, so every
  downstream check disappears.
  Behaviour: money.add(other) can reject mixed currencies,
  which a float cannot.
  Equality by value, which is what you actually mean.

THE PRIMITIVE OBSESSION IT REPLACES
  def transfer(amount: float, from_: str, to: str, cur: str)
  -> four positional arguments of two types, and
     transfer(100, "USD", "acc1", "acc2") compiles and is
     wrong.

Value objects are the cheapest tactical pattern and deliver the most per unit of effort, because they move validation from every call site into one constructor. Money, EmailAddress, DateRange and typed ids are worth it in almost any codebase, DDD or not.

Typed identifiers specifically:

# OrderId(UUID) and CustomerId(UUID) are different types.
# find_order(customer_id) is now a compile error rather
# than a production incident.

Concurrency: the aggregate is the unit

Because one transaction changes one aggregate, optimistic
locking is per aggregate root:

  UPDATE orders SET status=?, version=version+1
  WHERE id=? AND version=?

  0 rows -> someone else changed it -> reload and retry.

That is simple because the boundary is small. It is
unworkable if the aggregate is a customer plus every order
they have ever placed, because every order write contends
on one version column.

Which is the practical argument for small aggregates: the boundary you choose becomes your contention granularity, and a large aggregate is a lock everyone queues on.

Between aggregates: events, and the honest cost

order.ship() emits ShipmentRequested.
A handler updates the Shipment aggregate.

BETWEEN THOSE TWO COMMITS, the world is inconsistent:
the order says shipped and no shipment exists.

That is not a flaw, it is the design, and the question to
answer is how long that window may be and what happens if
the handler never runs.

  Transactional outbox for the event, so it cannot be lost.
  Idempotent handler, since delivery is at-least-once.
  A reconciliation job for the window where it never ran.

Being explicit that cross-aggregate consistency is eventual, and naming the outbox and the reconciliation, is what separates this from hand-waving.

A worked example: an aggregate that was too big

SYMPTOM
  A `Customer` aggregate containing the customer, their
  addresses, their payment methods, their orders and their
  support tickets. Loading a customer to update their email
  loaded 400 orders. Write contention on the version column
  caused constant retry failures on a busy account.

THE INVARIANT AUDIT
  "A customer must have at least one address."
      -> spans Customer + Address. Real.
  "A default payment method must be one of the customer's
   payment methods."
      -> spans Customer + PaymentMethod. Real.
  "Orders must not exceed the credit limit."
      -> spans Customer + ALL Orders. Real, and this is the
         one that forced the giant aggregate.
  "A support ticket references a customer."
      -> not an invariant. Just a reference.

THE REDESIGN
  Customer aggregate: customer, addresses, payment methods.
    Small, and it protects two real invariants.
  Order aggregate: order + line items, referencing
    customerId by identity.
  SupportTicket aggregate: separate entirely, referencing
    customerId.

  The credit limit rule became EVENTUAL:
    - Order placement checks a cached credit position.
    - A domain event updates the position.
    - A reconciliation job catches the race.
    - And the compensating action for an exceeded limit was
      defined explicitly: hold the order for review rather
      than reject it, which the business preferred anyway.

RESULT
  Loading a customer to change an email loads three
  addresses and two payment methods.
  Order writes no longer contend on the customer version.
  The credit-limit race was measured at about 1 in 40,000
  orders and the review-hold handled it.

THE FINDING
  The giant aggregate existed to enforce ONE invariant
  transactionally, and that invariant did not need to be
  transactional. The business was happy with a review hold,
  and nobody had asked.

That is the generalisable move: when an invariant forces an unworkably large aggregate, ask the business whether it must be transactional. Frequently it must not, and the eventual version with a compensating action is both simpler and closer to what they actually wanted.

Production evidence

Eric Evans, Domain-Driven Design (2003), defines aggregates, entities and value objects, and is explicit that the aggregate is a consistency boundary rather than a containment hierarchy.

Vaughn Vernon's "Effective Aggregate Design" (a three-part essay, 2011) is the practical reference for the four rules, particularly "reference other aggregates by identity" and "use eventual consistency between aggregates", and it works through the too-large-aggregate failure in detail.

The optimistic-locking pattern per aggregate root is standard in every ORM that supports versioning (JPA's @Version, SQLAlchemy's version_id_col, EF's concurrency tokens), which reflects that the aggregate is the accepted unit of concurrency control.

The transactional outbox pattern (Richardson, microservices.io) is the standard mechanism for publishing the domain events that carry cross-aggregate effects, and it is what makes "eventual consistency between aggregates" reliable rather than best-effort.

Value objects and primitive obsession predate DDD as a refactoring (Fowler's Refactoring), and their cost-benefit in ordinary codebases is why they are worth recommending independently of whether a team is doing DDD.

The debate

The case for strict aggregates: invariants that are enforced in one place are enforced, full stop. Every alternative relies on every code path remembering to call a validation service, and in a large codebase some path will not.

The case against the ceremony: aggregate discipline costs real ergonomics. Referencing by identity means an extra query whenever you want the customer's name, and one-aggregate-per- transaction means orchestration for operations that used to be a single transaction. For a CRUD application with few invariants, that is cost for nothing.

The case for value objects regardless: they are cheap, they are the highest-return tactical pattern, and they help any codebase whether or not it is doing DDD.

My position: draw aggregates around real invariants, keep them small, reference by identity, and be willing to make an invariant eventual when it forces an unworkable boundary.

The test I would apply is "is there an invariant that would be violated if code could load a partial version of this thing?" If yes, the aggregate is enforcing something real. If no, it is ceremony, and a plain data model with query objects is better. That is the same test I would apply to the repository pattern, and for the same reason: both are worth their cost only when a boundary is load-bearing.

The rule I would defend hardest is reference other aggregates by identity. It is the one people break first, because an object reference is more convenient, and breaking it is how an aggregate becomes the whole object graph. Once order.customer.orders exists, nothing constrains the boundary any more and you get the 400-order customer load.

The move that matters most in practice is questioning whether an invariant must be transactional. In the worked example one rule, the credit limit, forced an aggregate containing a customer and every order they had ever placed, with all the contention that implies. The business was perfectly happy with a review hold on an exceeded limit, which is eventual, and nobody had asked. When an invariant forces an unworkable boundary, that is a prompt to check the requirement rather than to accept the boundary.

And on value objects I would go further than DDD requires: Money, EmailAddress, DateRange and typed ids are worth it in almost any codebase, because they move validation from every call site into one constructor and turn find_order(customer_id) from a production incident into a compile error. That is the highest-return item in the tactical set and it needs no buy-in.

Where I would set expectations: this is the recoverable half of DDD. Getting an aggregate boundary wrong is a refactor. Getting a bounded context wrong becomes a distributed transaction and persists for years, which is why the strategic half deserves the attention it usually does not get.

Follow-up Q&A

"What is an aggregate?" A consistency boundary, not an object graph. It is a cluster of entities and value objects with one root, and the boundary is drawn around invariants: rules that must hold at every commit. An Order and its line items are one aggregate because the total must equal the sum of the lines and an order cannot ship with a backordered line. An Order and its Customer are not, even though the object graph connects them, because no rule spans both.

"How do you decide the boundary?" List the invariants as sentences, note which entities each spans, and let that force the grouping. The interesting case is an invariant spanning many aggregates, like a credit limit across all of a customer's orders: enforcing that transactionally means one aggregate containing the customer and every order they have ever placed, which is a lock on every order write. So it becomes eventual with a compensating action, and being explicit that this is a trade rather than an oversight is the point.

"What's the rule people break first?" Reference other aggregates by identity rather than by object: order.customerId, not order.customer. An object reference is more convenient, and once one exists you get order.customer.orders and nothing constrains the boundary any more. That is how you end up loading four hundred orders to change an email address.

"Why does aggregate size matter beyond ergonomics?" Because the boundary is your contention granularity. Optimistic locking is per aggregate root, so a large aggregate means every write to anything inside it contends on one version column. In a case I worked, a Customer aggregate containing all their orders caused constant retry failures on busy accounts, and the fix was splitting it rather than tuning the retries.

"What do you do when an invariant forces a huge aggregate?" Ask the business whether it has to be transactional, because frequently it does not and nobody has asked. The credit-limit rule in that example was the sole reason for the giant aggregate, and the business was perfectly happy with a review hold on an exceeded limit, which is eventual. The race was about one in forty thousand orders and the hold handled it. When an invariant forces an unworkable boundary, that is a prompt to check the requirement.

"How do aggregates communicate?" Domain events, with one transaction changing one aggregate. And the honest part is that between the two commits the world is inconsistent: the order says shipped and no shipment exists yet. That is the design rather than a flaw, and the questions to answer are how long the window may be and what happens if the handler never runs. So: a transactional outbox so the event cannot be lost, an idempotent handler because delivery is at-least-once, and a reconciliation job for the case where it never ran.

"Which tactical pattern is worth it regardless?" Value objects, easily. Money(40, "USD") rather than a float and a string moves validation from every call site into one constructor, gives you equality by value which is what you actually mean, and lets money.add(other) reject mixed currencies. And typed identifiers, so find_order(customer_id) is a compile error rather than a production incident. Those are worth it in any codebase whether or not the team is doing DDD.

"When is aggregate discipline not worth it?" When there are no real invariants. The test is whether something would be violated if code could load a partial version of the thing. For a CRUD application over a data model with few rules, the discipline costs an extra query whenever you want a related name and orchestration for operations that used to be one transaction, and it buys nothing. That is the same test as for the repository pattern, and for the same reason: both are worth their cost only when the boundary is load-bearing.

Common misconceptions

"An aggregate is a class with its children." It is drawn around invariants. An object graph connecting two entities does not put them in one aggregate.

"Bigger aggregates are safer because more is consistent." They are your contention granularity, and a large one is a lock everyone queues on.

"Reference by identity is a performance optimisation." It is what keeps the boundary a boundary. Break it once and the aggregate becomes the whole graph.

"Eventual consistency between aggregates is a compromise." It is the design. What is missing when teams get it wrong is the outbox, the idempotent handler and the reconciliation job.

"DDD means aggregates." That is the tactical half and the recoverable one. Context boundaries are where the expensive mistakes live.

Interview delivery note

Define it by what it is for, because "cluster of objects" is the definition that teaches nothing: "An aggregate is a consistency boundary rather than an object graph. You draw it around invariants: an Order and its line items are one aggregate because the total must equal the sum of the lines and it can't ship with a backordered item. An Order and its Customer aren't, even though the graph connects them, because no rule spans both."

Give the failure it prevents: "Without it, twelve places load an Order, three of them without line items because they only need the status, and one of those sets it to SHIPPED. The rule lives in a service class those three don't call, so the invariant is violated by a path nobody reviewed against it."

Name the rule people break and why it matters: "The rule that does the work is referencing other aggregates by identity rather than by object. It's the first one people break because an object reference is convenient, and once order.customer.orders exists nothing constrains the boundary. I worked on a Customer aggregate that loaded four hundred orders to change an email."

Then the move that shows judgement: "And when an invariant forces an unworkable boundary, I'd go back to the business rather than accept it. That credit-limit rule was the sole reason for the giant aggregate, and they were perfectly happy with a review hold on an exceeded limit, which is eventual. Nobody had asked."

Close by placing it: "Though this is the recoverable half of DDD. A wrong aggregate boundary is a refactor; a wrong bounded context becomes a distributed transaction and lasts for years, which is why the strategic half deserves the attention it usually doesn't get."

Further reading

  • Eric Evans, Domain-Driven Design (2003), part II, on aggregates as consistency boundaries.
  • Vaughn Vernon, "Effective Aggregate Design" (2011), the three-part essay, for the four rules and the too-large-aggregate failure.
  • Chris Richardson, microservices.io, "Transactional outbox", for reliable cross-aggregate events.
  • Martin Fowler, Refactoring, on primitive obsession, for value objects independent of DDD.
  • DDD strategic design, for the half that determines service boundaries.