06 · Life cycle of a domain object
When you finish this lesson you should be able to:
- State the two life-cycle problems Evans is solving (integrity, and not drowning in mechanics)
- Draw an aggregate with a root, a boundary, and a transactional invariant
- Recite the implementation rules for references, find, delete, and commit
- Hide construction and persistence so the application speaks in roots
Two problems, three patterns
Section titled “Two problems, three patterns”Every object is born, changes, and is archived or deleted. Transient values can be new and forgotten. Long-lived objects:
- Must keep invariants through every state, including after a crash and a reload.
- Must not swamp the model with loading, caching, SQL, and construction spaghetti.
Aggregates tighten the model: ownership and boundaries, not a tangled web.
Factories start (and reconstitute) complex clusters without leaking insides.
Repositories find persistent roots without turning the domain into JDBC.
Factories and repositories are not “from the domain” as business nouns; they are still part of model-driven design because they are how you hold the model in your hands.
Aggregate: root and boundary
Section titled “Aggregate: root and boundary”An aggregate is a cluster treated as a unit for data changes.
- Boundary: what is inside.
- Root: one entity. The only thing outside may hold a reference to.
- Other entities inside have local identity (a message id unique in this thread). Outsiders never fetch them except through the root.
Evans’s car: the car is the root for a repair shop’s “this vehicle.” Tires might be reachable only via the car. An engine might be a separate aggregate if the world tracks engines with their own identity (a rebuilt engine moved to another car). The question is always: what is the consistency boundary?
Implementation rules (this is the part people skip)
Section titled “Implementation rules (this is the part people skip)”- Outside the boundary, references only to the root.
- Inside, objects may reference each other and may reference other roots by id.
- Only roots are obtained from repositories.
- Delete removes the whole cluster (or you do not delete; you archive the root).
- On commit, every invariant of the cluster holds.
Invariants that span aggregates are not guaranteed in one transaction. Design them as eventual (domain events, retries, idempotency) or admit you drew the boundary wrong.
Copying a value into the aggregate (price copied onto a line item) is how you keep an invariant when the catalog’s price will change later. That is a modeling choice, not a cache.
Factories
Section titled “Factories”When construction is complicated or would expose internals, a factory produces a valid aggregate.
Thread.start({participantIds, body})notnew Thread(); messages.push- Factories reconstitute from storage too (
Thread.fromSnapshot), still without letting the app poke children. - A factory is allowed to be a function, a method on the root, or a small class. It is not your DI container.
If the application must know the order of setX calls to avoid a corrupt thread, you have no factory.
Repositories
Section titled “Repositories”A repository is a collection-like interface for roots.
threads.get(id)threads.forUser(userId)threads.add(thread)Inside: SQL, cache, search, memory. The domain does not see it.
Repositories:
- do not return children as first-class finds (
getMessageByIdfrom the world) - do not absorb every report (
messages containing the word refund in Q3)—that is a query model - do not replace the aggregate:
UPDATE messages SET archived=1from a job is a smashed boundary
Relational databases
Section titled “Relational databases”You will have tables. A join is not an aggregate. Map tables into roots. If a query needs five aggregates, it is a report, not a reason to make one giant root.
Example: inbox vs delivery
Section titled “Example: inbox vs delivery”InboxThread aggregate: messages, read cursors. Invariant: no append when archived; message belongs to one thread. Factory: start. Repository: get, add.
PushDelivery aggregate: deliveryId, attempts, provider result. Invariant: two workers cannot both send the same id to the vendor.
Application: thread.append(...); then record DeliveryRequested with a stable id. The thread does not import the push SDK. Retry is delivery’s life cycle. Idempotency is the delivery root’s identity.
flowchart LR app[Application] repo[ThreadRepository] root[InboxThread] kids[Messages and cursors] app --> repo repo --> root root --> kids
Drill (20 minutes)
Section titled “Drill (20 minutes)”Experiments:
- Catalog vs Assignment as two aggregates. One invariant each. Name the roots.
- Kill switch: which root do you load? What may lag (serving nodes, caches)?
- Write
assign(experiment, userContext)as a factory. First state must be valid. - Two repository methods. Delete any that smell like BI.
- If kill switch requires updating every assignment row, redraw the boundary (Assignment is computed, or serving reads catalog as source of truth).
Further reading
Section titled “Further reading”- Domain-Driven Design — Chapter 6, The Life Cycle of a Domain Object (aggregates; factories; repositories; relational mapping)