Software Architecture · Domain Modeling
Repositories and the Persistence Boundary
What a repository should expose, why it belongs to the aggregate root rather than the table, and which storage concerns it cannot hide from the model.
The first sign that persistence has entered the model is a method named save on a domain type. The second is a constructor that takes a database connection. Both are convenient, and both make the model depend on a store that the model is supposed to describe rather than contain. A repository is the standard answer: an interface that presents stored aggregates as a collection, so that the domain can ask for the objects it needs without knowing how they are found.
An interface shaped like a collection
The metaphor is deliberate. In memory, a collection of orders supports adding one, finding one by identifier, and removing one. A repository exposes the same three operations and nothing else at the base level. Code that uses it reads as if the whole set of orders were available, while the implementation decides whether that means a SELECT, an HTTP call, or a map.
The value is not indirection for its own sake. It is that the interface can be implemented twice: once against the real store, once in memory. The in-memory version makes domain tests fast and, more importantly, makes them test the model rather than the mapping. A test that needs a database to verify that an order cannot be shipped twice is testing two things at once and will fail for reasons unrelated to the rule.
Two principles keep the interface honest. It is defined per aggregate root, not per table, because the aggregate is the unit of consistency and the unit of load — a repository for OrderLine would invite callers to load lines without the order that guards them. And it returns fully formed domain objects, not rows or partial projections, so that every object handed out satisfies its invariants. That second principle is what distinguishes a repository from a data access object.
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
add(order: Order): Promise<void>;
remove(order: Order): Promise<void>;
nextIdentity(): OrderId;
}
The nextIdentity method is there because identifier generation belongs with the store in many designs: a sequence-backed identifier cannot be produced without asking the database. Where identifiers are generated in the application, the method is unnecessary. Either way, the decision is explicit rather than hidden in a constructor.
Loading and saving whole aggregates
findById returns the aggregate with all of its parts. That is what allows a method on the root to enforce an invariant across the parts: the root must see the complete set of lines to check that a total matches. A repository that offers a way to load an aggregate partially — “just the header, for performance” — breaks the contract, because the object it returns cannot be trusted to be valid.
The cost is real. An aggregate with a large collection loads that collection on every access, even when the caller only needs one field. The usual mitigations are to keep aggregates small (the sizing argument in Aggregate Boundaries and Transaction Scope) and to bypass the repository for read-only screens, querying a projection directly. The second is not a violation if the projection is not used for decisions: a report that lists order numbers and dates does not need the aggregate, and loading it would be waste. The danger is a read model reused for a write decision, because it has no invariants.
Saving is the other half. add and remove are distinct because the store treats them differently and because conflating them into save hides the question of whether the object is new. Whether an update writes every column or only changed ones is an implementation matter; what the model must not do is call save in the middle of a multi-step operation, because that exposes a partially applied change to other readers.
An identity map, inside a unit of work, ensures that loading the same aggregate twice in one transaction returns the same instance. Without it, two loads produce two objects, both valid, and a change through one is lost when the other is saved. The map is keyed by type and identifier and lives for the duration of the transaction, which is why the transaction boundary and the repository are designed together.
Queries that do not fit the collection metaphor
The collection metaphor breaks as soon as the application needs to ask a question the collection interface does not support: orders shipped in the last week, unpaid invoices over a threshold, customers whose credit limit is below their outstanding balance. Adding a method per query turns the repository into a query surface that grows without bound and that every implementation must support.
Two strategies are common. The first adds domain-specific finders to the repository interface, named after the question rather than the SQL: findOverdueInvoices(asOf). Each one is a real domain concept, can be implemented in memory, and can be tested without a database. The interface grows, but only in proportion to the questions the domain actually asks.
The second passes a specification object: a small predicate that can be evaluated in memory and translated to a query by the implementation. This keeps the interface small and pushes the variety into the specification types, at the cost of writing a translator for each storage technology. The specification pattern is the more elegant option and the more work; the named finder is the more common one in practice, and it has the advantage that each query can be optimized individually.
The thing to avoid is a generic find(criteria) that accepts a map of field names and values. It looks flexible and it leaks the schema: the caller now knows the column names, and the model’s naming discipline, described in Ubiquitous Language, is defeated at the one place where names are hardest to change.
Transactions belong to the use case
A repository method that opens its own transaction makes every call atomic and makes multi-step operations impossible to keep atomic. The correct arrangement is for the transaction to be owned by the use case: begin, load through repositories, call domain methods, commit. The repositories join the ambient transaction; they do not start one.
This has consequences for retries. If the commit fails on a version conflict, the whole use case must be retried from the load, because the in-memory aggregates are stale. A retry loop inside the repository cannot do that — it has no way to reset the domain objects it did not create. The retry belongs at the use case boundary, where the objects can be discarded and rebuilt.
It also has consequences for lazy loading. An ORM that defers a collection load until first access will happily issue a query when the object is touched after the session closed, and the failure appears as an exception at an arbitrary point in the code. Eager loading of the aggregate’s parts at findById, or explicit loading of the parts a use case needs, keeps the object usable for its whole lifetime. The trade is that the load is bigger than strictly necessary, which is usually the correct trade for aggregates that are meant to be loaded whole.
What the abstraction cannot hide
Three storage realities leak through any repository design, and pretending otherwise causes more trouble than admitting them.
The first is that the store is shared. Two application instances writing the same aggregate concurrently will conflict regardless of how clean the interface is, so concurrency control must be visible somewhere — usually as a version field on the aggregate or an exception thrown at commit. An interface that hides conflicts entirely makes them appear as mysterious lost updates.
The second is that transactions have isolation levels with different visibility rules. A repository that assumes read-committed semantics will behave differently under repeatable-read or serializable, and the difference shows up as either a phantom row or a serialization failure. The level is a property of the use case, not the repository, so the use case has to know it.
The third is that deletion and archival are not the same operation. A remove that issues a DELETE is easy until a record must be retained for compliance, at which point the model needs a notion of an inactive or superseded record rather than a missing one. That is a modelling decision, not a storage detail, and it belongs in the aggregate. The same question of retaining history is central to Modeling Validity Over Time.
A repository earns its keep when it keeps these facts at the edges while giving the model a coherent view of stored state. It fails when it is used to pretend that persistence is free, which shows up as an aggregate that calls save between two mutations and cannot be rolled back.
References
- Patterns of Enterprise Application Architecture — Martin Fowler, Addison-Wesley, 2002
- Domain-Driven Design: Tackling Complexity in the Heart of Software — Eric Evans, Addison-Wesley, 2003
- Implementing Domain-Driven Design — Vaughn Vernon, Addison-Wesley, 2013