Lesson 0013 · Core Designer · Module 3
Repository Boundaries That Don't Leak
A repository is a collection, from the domain's point of view. When it starts showing its storage internals, the domain stops being protected.
Mission tie-in: the repository is the port from lessons 0009 and 0010, specialized for storage. It's also the boundary your AI prompts should name explicitly.
Knowledge: a collection, not a query API
The repository's job: let the domain load and save aggregates as if they lived in a collection. The domain never learns about SQL, sessions, or ORMs. The port speaks domain language:
class OrderRepository(Protocol):
def get(self, order_id: str) -> Order: ...
def add(self, order: Order) -> None: ...
def delete(self, order_id: str) -> None: ...
class SqlAlchemyOrderRepository:
def __init__(self, session: Session) -> None:
self._session = session
def get(self, order_id: str) -> Order:
row = self._session.execute(
select(OrderRow).where(OrderRow.id == order_id)
).scalar_one()
return Order(...) # row -> domain aggregate
def add(self, order: Order) -> None:
self._session.add(OrderRow.from_domain(order))
The boundary's proof is the fake — a drop-in replacement with no storage at all:
class InMemoryOrderRepository: # implements the same port
def __init__(self) -> None:
self._orders: dict[str, Order] = {}
def get(self, order_id: str) -> Order:
try:
return self._orders[order_id]
except KeyError:
raise OrderNotFound(order_id) # the port's vocabulary, not the ORM's
def add(self, order: Order) -> None:
self._orders[order.order_id] = order
Twelve lines, no database, and every domain test runs against it in milliseconds. That is also the diagnostic: if your fake cannot be written in a dozen lines, the port is carrying storage semantics it should not — lazy loading, session lifetimes, flush ordering. Note the error too: the fake raises OrderNotFound, not KeyError and not the ORM's NoResultFound. The port owns its failure vocabulary as much as its methods.
What makes this boundary honest is what it refuses:
- No
session,query, or ORM types appear in the port or in domain callers. - Queries are expressed as domain questions:
get_unshipped(), notfilter(status=...)with raw column names. - The domain gets domain objects back — never rows, dicts, or ORM models.
- Persistence details — transactions, lazy loading, caching — live behind the boundary.
Test it the lesson-0009 way: a fake in-memory repository must be a drop-in, and the domain must never know which one it holds. If your fake has to mimic ORM behavior, the boundary leaked.
The repository pays off because storage and domain change at different rates, for different reasons, under different people. Schema migrations, connection pooling, ORM upgrades and query tuning arrive on the infrastructure clock; refund rules and eligibility arrive on the product clock. A boundary between two clocks means a change on one side stops propagating to the other — which is the same argument as lesson 0006, applied to the noisiest dependency most systems have. The measurable proof is test cost: domain tests that no longer need a database run in microseconds instead of seconds, so they get run on every save instead of in CI, and the feedback loop that catches design mistakes tightens by two orders of magnitude.
It stops being true in three situations, and all three are common. First, when the query is the product: reporting, search, aggregation and analytics need the storage engine's expressive power, and a collection interface forces you to re-implement a query language, badly, in Python. Second, when storage semantics are domain semantics — set-based operations, window functions, "update the 40,000 matching rows" — where pretending the database is a collection means loading a million rows to do work the engine would do in one statement. Third, and least noticed: an interface may hide implementation, but it must not hide cost. A port that conceals how many round trips a call makes has swapped one leak for another, and the second one only shows up in production.
How a senior designer works the case
An order service: eight engineers, twenty-five endpoints, a repository boundary everyone is proud of. After one sprint, the order-list page's p95 goes from 180 ms to 1.4 s. The ticket says "the repository is slow."
What they look at first. Not the SQL, and not the adapter. The call site, next to the port's signature:
# the port looks immaculate
class OrderRepository(Protocol):
def get(self, order_id: str) -> Order: ...
# the caller wrote the loop the port invited
def order_list(order_ids: list[str], repo: OrderRepository) -> list[OrderSummary]:
return [summarize(repo.get(oid)) for oid in order_ids] # 50 round trips
The question they ask. "Which side of this boundary decides how many round trips happen?" Here, neither did on purpose: the caller wrote an innocent comprehension, the port had no way to express "fifty at once", and the fake — which returns from a dict — made the loop look free in every test. That is the diagnosis. The port hid cost, and the fake made the hiding invisible.
The alternatives they reject. The quick fix is get_many(ids), then get_with_lines(ids) for the next screen, then get_with_lines_and_shipping(ids). Each is reasonable alone; together they grow one method per screen, so a write-model interface slowly becomes a read API with nineteen methods, six of them used once, all of which the fake and every implementation must support forever. The other tempting fix — switching the ORM to eager loading globally — repairs this page and pessimizes every write path, and makes the port's behaviour depend on a configuration line in a different file.
What they choose, and what it costs. Split the two jobs the boundary was doing. The repository stays narrow and aggregate-shaped for the write side: load one thing by identity, change it, save it. A separate read side — a query service — is allowed to know SQL, because reading is a query:
@dataclass(frozen=True)
class OrderSummary: # a read model, not a domain aggregate
order_id: str
customer_name: str
line_count: int
total_cents: int
class OrderQueries(Protocol): # a separate port, read-only
def recent_summaries(self, limit: int) -> list[OrderSummary]: ...
The consequence over time. Two boundaries with different rules is more code than one, and the honest cost is a second mapping path that can drift from the first. What it buys is that neither side has to lie: the domain keeps its twelve-line fake and its microsecond tests, the list page gets one statement instead of fifty, and the next screen adds a read model instead of another method to an interface eleven classes implement. The failure mode you avoid is the one that kills repositories in practice — a port that grows until nobody can implement it and everybody routes around it.
† Leak test: grep the domain folder for the ORM's import. Any hit is a hole — the dependency arrow from lesson 0009 is pointing the wrong way.| You see this | What it costs | The move |
|---|---|---|
| session in a port signature | Every caller has to know an ORM exists. | Take and return domain objects only. |
| query(Order).filter(...) in a service | SQL vocabulary in domain code; the fake must mimic an ORM. | repo.get_unshipped() — a domain question. |
| a repository returning OrderRow | Rows leak the schema; every caller learns the columns. | Map row to aggregate inside the adapter. |
| repo.save() called twice “to be safe” | Transaction semantics leaked, and were misunderstood. | One unit of work owns the commit. |
- "One repository per table."
- One per aggregate (lesson 0012). Tables are a storage decision; a single aggregate frequently spans three of them, and a repository per table forces callers to reassemble the aggregate — which puts the storage layout back in the domain, the exact leak the boundary existed to close.
- "Every repository needs a save() method."
- Often the transaction, not the repository, owns the commit. When each object saves itself, a caller changing three objects performs three writes with no boundary around them, and partial failure leaves the system in a state no invariant covers. Let a unit of work own the commit; the repository owns retrieval and registration.
- "A generic Repository[T] with CRUD methods is good reuse."
- It is the storage vocabulary again with generics on top.
create/read/update/deleteare not domain questions, and the methods you actually want —get_unshipped(),with_pending_returns()— differ per aggregate precisely because the domains differ. Shared base classes here mostly share the leak. - "If the fake passes, the boundary is verified."
- The fake tests your code against your beliefs about storage. It has no unique constraints, no lazy loading, no isolation levels and no ordering surprises, so it will happily confirm a design the real database rejects. Run the same suite against the real adapter as a contract test — occasionally is enough — or the fake becomes a comfortable lie.
When this is the wrong move
- Reporting, search and analytics. Go to the engine directly, with a read model. A repository between a dashboard and a warehouse query buys nothing and costs expressiveness.
- Set-based work. "Expire the 40,000 sessions older than a day" is one statement in SQL and a disaster as a loop over a collection. Put it behind a named domain operation, and let the adapter do it in one round trip.
- Small applications where the ORM model is the model. One team, one storage, CRUD rules, no plans to swap: the port is ceremony, and the honest design is to admit the framework is the architecture. Say so in an ADR so the next person knows it was a decision.
- Spikes. A boundary you introduce before you know the domain's questions will be a boundary shaped by your first guess. Write the ugly version, learn the queries, then draw the line.
Skill: what crosses the boundary?
A repository port may expose:
"Find orders waiting for shipment" should appear in the repository as:
A fake in-memory repository proves the boundary works when:
Practice on your own code
Look at how your code fetches and saves domain objects. Grep the domain folder for your ORM's import. If it appears — or if the boundary doesn't exist yet — introduce a repository port with domain-named methods and move the ORM behind it.
In the lab: the phase-1 checkpoint has a returns store that hands ORM rows and a live session back to the service layer, and a list endpoint that loops one lookup per return. You give the port a domain vocabulary, write the in-memory fake, and decide which of the remaining calls belongs on a read side instead.
Reveal: the leak audit
A CustomerService calls session.query(CustomerModel).filter_by(...) directly. Grep shows the ORM import inside the domain service. Fix: a CustomerRepository port with get_active(), implemented by an adapter that owns the session. The service now states its data needs as questions, not SQL.
- In two sentences, explain to a junior why a repository interface that hides the number of database round trips is still a leak.
- Find one data-access call in your code this week whose cost is invisible at the call site. Name what it would cost at ten times today's volume.
- Assume every new screen adds one method to your repository interface. Describe what that interface looks like in a year, and who stops using it first.
Your win
You can design a repository boundary that speaks domain language, run the leak test (grep the ORM import), and prove it with an in-memory fake.
Read and watch deeper
- Architecture Patterns with Python, Percival & Gregory — ch. 2 "The Repository Pattern": the clearest working treatment, with the fake-repository test.
- Implementing Domain-Driven Design, Vernon — ch. 12 "Repositories": design rules for repository interfaces.
- Domain-Driven Design, Evans — ch. 6: the original repository concept, and why it exists.
- Patterns of Enterprise Application Architecture, Fowler — the Repository entry in the object-relational metadata mapping patterns: the original catalog definition, and the Query Object it is usually paired with.
- Fowler, "CQRS" — the argument for letting the read side and the write side have different models, with an explicit warning about when the split is not worth it.
- Watch: ArjanCodes YouTube — search "repository pattern".
- Reference: Patterns — Repository and Unit of work, the transaction boundary the repository sits inside.
Show your agent-teacher your data-access code and audit the boundary together — grep included.