08 · Aggregates and boundaries
When you finish this lesson you should be able to:
- Draw an aggregate: a cluster of objects with one consistency rule and one entry point
- Say which writes must be atomic and which can be eventually consistent
- Keep two models from pretending they share a database when they do not share a language
Core idea
Section titled “Core idea”Not everything should be one graph you update in a single transaction. An aggregate is a boundary: inside, invariants hold together; outside, you talk through an id.
Pick the aggregate root (the thing you load to make a decision). Children do not get their own independent writes from the rest of the world. Factories create the cluster in a valid state. Repositories load and save the cluster, they are not a junk drawer of queries.
Across aggregates, you accept lag. You send a domain event, you retry, you make the receiver idempotent. “Exactly once” across two aggregates is usually a story people tell; “at least once + a natural key” is a design.
A bounded context is a larger boundary: the word Account in billing is not the word Account in identity. Do not smash them into one table because the strings match. If two teams (or two boxes on your board) would argue about a definition, you have two contexts and a mapping, not one god model.
Example
Section titled “Example”Messaging.
MessageThread aggregate: thread id, participants, messages, read state. Invariant: you cannot add a message to an archived thread; read state is per participant. You load the thread to append.
Delivery aggregate (or a separate context): channel, provider id, attempt count. Invariant: two workers cannot send the same deliveryId to the provider.
If you try to make “append message + send push” one transaction across your inbox DB and a third-party push vendor, you will invent distributed pain. Append in the thread, record a DeliveryRequested with a stable id, let a worker send, record the provider result. Retry is safe because deliveryId is the idempotency key.
flowchart LR client[Client] thread[MessageThread] outbox[DeliveryRequested] worker[Delivery worker] provider[Push provider] client --> thread thread --> outbox outbox --> worker worker --> provider
The dotted conceptual line is the boundary: the thread does not know HTTP retries. The worker does not decide whether the user is allowed to speak in the thread.
Drill (15 minutes)
Section titled “Drill (15 minutes)”Boxes and one invariant each.
- Prompt: “Flags plus experiments: a user must stick to the same variation, and we must be able to ship a kill switch.”
- Propose two aggregates (hint: catalog/config vs assignment/serving).
- Write one invariant per aggregate.
- Describe a kill switch as a write: which aggregate do you load? What do serving nodes see, and how soon?
- Name the idempotency key if assignment is written more than once.
If your kill switch requires editing every assignment row, the boundary is wrong.
Further reading
Section titled “Further reading”- Domain-Driven Design — Chapter 6, The Life Cycle of a Domain Object
- Domain-Driven Design — Chapter 14, Maintaining Model Integrity (Bounded Context, at a high level)