In Domain-Driven Design, concretely, what mechanism does an aggregate root use to guarantee that its invariants are never violated, from the moment a command comes in to the moment the change is persisted?
answer
- behavior methods not setters
- load-mutate-save one atomic write
- one aggregate one transaction
- optimistic locking version field catches races
- race = two valid-looking calls combine into invalid state
basics
~20 sOnly the aggregate root's own methods can change its data — no direct field edits. Each method checks the rules first, then the whole change saves as one atomic write, so nothing half-valid is ever stored.
solid answer
~40 sEnforcement happens in three layers: (1) encapsulation — the root exposes behavior-named methods (e.g., addLine, cancel) instead of public setters, so every mutation path runs through code that can check invariants first and either apply the change or throw/reject; (2) the load-mutate-save cycle — a repository loads the full current aggregate state, the root method mutates in memory, and the repository persists the whole aggregate as one atomic write (usually one DB transaction), so partial states never reach storage; (3) concurrency control — a version field (optimistic locking) or a row lock ensures two concurrent commands against the same aggregate instance can't both apply based on stale state, which would let two individually-valid changes combine into an invalid one.
go deeper
Knows an aggregate should check rules before changing data; may not yet see why a single check inside one method isn't enough under concurrency.
Understands encapsulated methods plus atomic save; can describe the load-mutate-save cycle but may not have hit or reasoned about the race-condition case yet.
Explains all three layers together, can describe a concrete race scenario optimistic locking prevents, and knows why cross-aggregate transactions are discouraged.
Chooses between optimistic and pessimistic strategies per aggregate based on measured contention, and designs retry/backoff or command-level idempotency around lock-conflict failures at the system level.
## The three things working together At its core, an aggregate root enforces its invariants through a combination of **encapsulation**, a disciplined **load-mutate-save lifecycle**, and **concurrency control** — and understanding how these three work together is what separates a correctly implemented aggregate from a data-holding object that merely looks like one. ## One deliberate way in The first layer is **behavioral encapsulation**. An aggregate root does not expose public setters for its fields; instead it exposes methods named after domain actions: - `order.addLine(product, quantity)` - `order.cancel()` - `shoppingCart.checkout()` Each of these methods is the single, deliberate entry point for a specific state transition, and because it's the only path in, it's also the only place invariant checks need to live. Inside `addLine`, for example, the method can check "is this order still in a state that allows new lines?" and "does adding this line keep total item count under any configured maximum?" before mutating anything; if a check fails, the method throws a **domain exception** and the object's state is left untouched. This is fundamentally different from a plain data class with public getters/setters, where any caller can independently set `order.status = CANCELLED` and `order.total = -5` in two separate statements, with nothing in between checking whether that combination makes sense — because there is no single chokepoint any check could live in. ## Load it whole, save it whole The second layer is the **load-mutate-save cycle** managed by a **repository**. When a command handler needs to change an aggregate, it does not construct a fresh, partial version of it — it asks the repository to load the entire current aggregate (root plus all child entities and value objects) from storage, calls one or more root methods on the in-memory object graph, and then hands the whole thing back to the repository to persist. - The persistence step writes the aggregate's new state as a **single atomic operation** — typically one database transaction that updates the root row and any child rows together (or, in an event-sourced system, appends one batch of domain events representing the change). - Because the write is atomic, there is no window where storage contains, say, an `Order` with three `OrderLines` but a total reflecting only two — either the whole new consistent state lands, or the old consistent state remains. This is precisely why DDD conventionally treats **"one aggregate, one transaction"** as close to a hard rule: spanning a database transaction across two aggregates would mean two independent repositories both need to succeed or both roll back together, which reintroduces the tight coupling and locking cost that aggregate boundaries exist to avoid. ## Two commands arriving at once The third layer, easy to overlook, is **concurrency control across separate command invocations**. Encapsulated methods and atomic single-aggregate transactions solve the "one command in flight" case, but two different commands can arrive concurrently against the same aggregate instance — for example, two warehouse-transfer requests both trying to decrement the same inventory aggregate's stock count. 1. If both load the aggregate at `count=10`, 2. both individually decrement by 6 inside a valid-looking method call, 3. and both then save, the naive result is `count=4` written twice, silently allowing 12 units to be reserved against 10 in stock — an invariant violation that no single method call would have permitted in isolation, because it emerges from the interleaving of two calls. This is what **optimistic concurrency control** (a version column incremented on every save, checked via a `WHERE version = expectedVersion` clause) or, less commonly, **pessimistic row locking** is for: the second save in the race detects that the version it started from is stale and fails (typically with a version-conflict exception), forcing the application layer to reload and retry rather than silently corrupting state. ## What the apparatus costs - The main trade-off of this whole apparatus is **throughput and developer friction under contention**: a hot aggregate (one many concurrent commands target) generates frequent optimistic-lock failures and retries, and the strict "one entry point per mutation" discipline requires more upfront method design than a CRUD-style object with setters would. - The failure mode when teams skip this discipline is subtle and shows up late — it looks fine in single-user testing, then produces intermittent, hard-to-reproduce data corruption in production once real concurrent load appears, because the bug is a **race condition**, not a logic error visible in any single code path. ## Stock reservation as the worked case A concrete, widely used illustration is inventory/stock management: a `Warehouse` or `Stock` aggregate exposes a `reserve(quantity)` method rather than a public `quantityAvailable` setter, is loaded and saved as a whole with a version column, and any command that tries to reserve more than is available is rejected inside that one method — the exact pattern that prevents the double-decrement race described above, and the reason production e-commerce systems almost universally model stock reservation as a versioned aggregate rather than a raw counter updated by ad hoc SQL `UPDATE` statements issued from multiple services.
- Two concurrent requests both try to reserve the last unit of stock from the same inventory aggregate, each individually checking 'is stock >= 1' before decrementing. Without optimistic locking, what can go wrong, and why doesn't the method's own invariant check prevent it?Both requests can load the aggregate at stock=1, both pass the 'stock >= 1' check independently because each is reading a stale snapshot, and both then decrement and save, driving stock to -1 or double-reserving the same unit. The method's check is correct for a single call in isolation, but it can't see the other in-flight call's uncommitted change, so the invariant is violated by the interleaving of two individually valid operations, not by either one alone.
- Why is 'one aggregate per transaction' treated as close to a hard rule rather than just a guideline?Spanning one database transaction across two aggregates would require both repositories to commit or roll back together, which reintroduces cross-aggregate locking and coupling — exactly what splitting them into separate aggregates was meant to avoid. It also breaks the assumption that any single aggregate's invariants are self-contained and independently verifiable, since verifying them would now sometimes require inspecting another aggregate's mid-transaction state.
- What's the practical difference between optimistic and pessimistic locking for enforcing this, and when would you reach for pessimistic locking instead?Optimistic locking lets both commands proceed to load and compute, then rejects the loser at save time via a version check, which is cheap under low contention but wastes work under high contention because losers retry from scratch. Pessimistic locking (a DB row lock taken at load time) blocks the second command from even reading a mutable snapshot until the first finishes, which is better when contention on a specific aggregate is known to be high and retries would be expensive or user-visible.
Like a bank teller window: you can't reach into the vault yourself (no public setters), you hand the teller a request (call a method), the teller checks the rules and updates the ledger in one sealed transaction, and if someone else's transaction hit the same account first, yours gets rejected and you have to resubmit (optimistic locking).
saying these in an interview costs you the question
- exposes public setters on aggregate fields instead of behavior methods
- checks an invariant by reading current state without accounting for concurrent writers
- assumes a single method's internal check is sufficient to prevent races across separate calls
- wraps saves to two different aggregates in one database transaction as a design default
- doesn't know what an optimistic-lock/version-conflict exception is or when it fires