Software Architecture · Domain Modeling
Aggregate Boundaries and Transaction Scope
How to decide what loads and changes together in one transaction, why object references across those boundaries cause trouble, and how conflicts are detected.
A rule such as “the order total must equal the sum of its lines” is easy to state and expensive to enforce, because it constrains how much state a transaction must touch. If lines live in their own table and other code can append to it, then the total and the lines can diverge between two statements. The fix is not a stricter validator. It is a boundary: a set of objects that are loaded together, changed together, and committed together, with an entry point through which every change passes.
What has to be true at the end of a transaction
An aggregate is a cluster of objects treated as one unit for the purpose of data changes. One object in the cluster is the root; outside code holds a reference only to the root, calls methods only on the root, and the root enforces the invariants that span the cluster. The database transaction that persists a change covers exactly one aggregate. That last sentence is the whole design constraint: everything else follows from the choice of where the boundary sits.
The boundary should be drawn around invariants that must be true immediately, at commit time, rather than eventually. Something is a true invariant if a single statement in the domain would be false the moment it were violated: a seat cannot be sold twice, an invoice total must match its lines, a shipment cannot exist for an order that was never placed. Rules that tolerate a delay — a loyalty balance updated after an order completes, a report refreshed overnight — belong outside the aggregate, because including them widens the transaction and the lock.
The common mistake is to draw the boundary around a noun rather than around an invariant. An Order aggregate that contains Customer, Address, PaymentMethod, and Shipment reads naturally as an object graph and is a poor transaction unit: editing an address now conflicts with editing an order, because both take a lock on the same root, and any change requires loading data no invariant needs.
References by identifier, not by object
Inside the boundary, objects may reference each other directly. Across boundaries, they hold identifiers and look the other up when needed. An Order that stores a customerId rather than a Customer object cannot accidentally mutate the customer, cannot traverse into another aggregate’s private state, and can be loaded without joining to tables it does not need.
The direct-reference alternative fails in three ways. It makes loading transitive: fetching an order pulls in a customer, which pulls in its addresses and its billing profile, and the object graph grows until a single load touches a dozen tables. It makes the transaction span the graph whether or not the code intends it, because a lazy-loaded reference touched during a write acquires locks under the same transaction. And it makes identity ambiguous: two code paths that each load a customer within one transaction may receive two instances unless an identity map unifies them, and if they do not, an edit through one is invisible to the other.
The identifier approach has its own cost: every access that needs the related object requires a lookup, and a loop over a hundred orders that looks up each customer produces a hundred queries. The remedy is a batch load — fetch the set of identifiers in one query — and it is worth building that helper early, because the naive pattern is the one that gets written by default. This is the same separation that keeps persistence concerns out of the model, discussed in Repositories and the Persistence Boundary.
Sizing: contention versus consistency
Aggregate size is a trade along one axis. Larger aggregates make more invariants enforceable inside a transaction and increase the chance that two concurrent operations touch the same root. Smaller aggregates reduce contention and push more consistency into processes that must compensate when they fail.
The contention effect is not subtle. Two transactions that update the same row serialize; the second waits, and under a row-locking database it waits for the first to commit or roll back. If the transaction also does remote calls, the lock is held across network latency. An aggregate that includes a frequently changed counter — a view count, a last-seen timestamp — becomes a hot row that throttles every write in its cluster. The standard fix is to move such counters out of the aggregate, either into a separate record updated asynchronously or into an append-only log that is summed on read.
The consistency effect runs the other way. If inventory reservations and order placement are separate aggregates, a crash between the two leaves a reservation with no order, and some process must sweep it up. That process is real work: it needs an idempotent rule for what counts as abandoned, a schedule, and an alert when the sweep falls behind. Choosing a smaller aggregate is choosing to build that machinery.
A rough sizing test: if a proposed aggregate has a collection that can grow without bound, it is probably wrong. A collection whose size is bounded by the domain — the lines of a single invoice, the seats in one booking — can be loaded whole. A collection that grows with time, such as every order a customer has ever placed, cannot, and it is a sign that the parent is being used as a container rather than as an invariant boundary.
Concurrency control at the boundary
The transaction per aggregate model needs a way to detect two transactions that started from the same state. The usual mechanism is a version column: the aggregate carries an integer that increments on every successful write, and the update statement includes the expected version in its predicate.
UPDATE orders
SET status = 'SHIPPED',
shipped_at = :shippedAt,
version = version + 1
WHERE id = :id
AND version = :expectedVersion;
If zero rows are affected, another transaction committed first. The application then reloads the aggregate and decides whether to retry, whether the operation is still valid against the new state, or whether to report a conflict. The retry decision must live above the transaction, because the failed attempt may have left the in-memory object in a modified state; rebuilding from a fresh load is the only safe restart.
Two details are easy to get wrong. The version must be checked on every write, including writes that only touch a child table, or two concurrent changes to different children will both succeed and one will be lost. And the check must be part of the same statement as the write, or a compare-then-write gap appears between them. Databases that support SELECT ... FOR UPDATE offer an alternative by taking the lock up front, at the cost of holding it for the duration of the transaction.
For high-contention aggregates, pessimistic locking and optimistic retries both degrade: one queues, the other spins. The escape is to make the operation commutative — increment rather than set, append rather than replace — or to partition the aggregate. Neither is free, and both change the domain model rather than just the storage layer.
Invariants that cannot fit inside one aggregate
Some rules genuinely span aggregates: a username must be unique across all accounts, a room cannot be double-booked, a transfer must debit and credit. Each has a standard resolution, and none of them keeps the rule inside a single aggregate’s constructor.
Uniqueness is handled by a database constraint on the natural key. The aggregate does not check it; the insert fails, and the failure is translated into a domain error. This relies on the constraint being enforced by the same store that receives the write, which is why a uniqueness rule cannot be enforced by an in-memory set in a multi-node deployment.
Double booking is handled by a reservation record with a unique constraint on the resource-and-slot pair, so that the second attempt collides at the database rather than in application logic. The reservation is then a small aggregate in its own right, and the larger scheduling object references it.
Debit-and-credit across accounts is handled by a process manager or saga: a sequence of local transactions, each with a compensating action, coordinated by a component that records which steps completed. The intermediate states must be visible and legal, which means the accounts’ models must include a pending state rather than pretending the transfer is atomic. Domain Events and State Transitions covers how those intermediate states are published and consumed.
The recurring lesson is that these rules are not weaker because they sit outside an aggregate. They are enforced by a different mechanism — a constraint, a reservation, a compensating process — and the model has to represent that mechanism explicitly. A model that assumes atomicity it does not have produces code that is correct in tests with a single writer and wrong under concurrency.
References
- Domain-Driven Design: Tackling Complexity in the Heart of Software — Eric Evans, Addison-Wesley, 2003
- Effective Aggregate Design — Vaughn Vernon, 2011
- Life Beyond Distributed Transactions: an Apostate's Opinion — Pat Helland, CIDR 2007