Repository: a collection-like data access layer
Open in the interactive app: practice, quiz, mock interview →In one minute
- A repository makes stored domain objects look like an in-memory collection:
get(id),add(order),find(spec). The domain code asks for objects and hands them back; it never sees SQL, connection pools or document shapes. - It is a port in the Dependency Inversion sense: the domain defines the interface it needs, and a Postgres, DynamoDB or in-memory adapter implements it. That is what lets business rules be tested in milliseconds against a fake that behaves like the real thing.
- Repositories come with two companions. A unit of work tracks which loaded objects changed and commits them together in one transaction. Optimistic concurrency (a version column checked on every save) stops two requests that loaded the same row from silently overwriting each other.
- One repository per aggregate root, not per table. Order lines are loaded and saved through the order, never through an
OrderLineRepository. - The common failures are leaky ones: a generic
Repository<T>wrapped around an ORM that already is one, query methods that multiply into dozens, and hidden N+1 loading. Agents generate all three readily; review the interface, not just the implementation.
How it works
The problem
A subscription service has a renew(subscription_id) use case. Its first version opens a cursor, runs SELECT * FROM subscriptions WHERE id = %s, reads columns by index into local variables, applies the renewal rules, and runs an UPDATE with seven columns. The renewal rules (grace periods, price changes on renewal, a cap on failed charges) are mixed with column indexes and SQL strings. The tests need a Postgres container and take 40 seconds. When the team moves subscriptions into a separate table for add-ons, every use case that touched the old columns has to change.
The collection illusion
A repository moves all of that behind a small interface owned by the domain:
class SubscriptionRepository(Protocol):
def get(self, sub_id: str) -> Subscription: ...
def add(self, sub: Subscription) -> None: ...
def save(self, sub: Subscription) -> None: ...
def due_for_renewal(self, day: date, limit: int) -> list[Subscription]: ...
def renew(repo: SubscriptionRepository, sub_id: str, today: date) -> None:
sub = repo.get(sub_id)
sub.renew(today) # all rules live on the domain object
repo.save(sub)The use case reads like the business process. The SQL, the mapping from rows to a Subscription and the add-on join live in PostgresSubscriptionRepository. Tests use InMemorySubscriptionRepository, a dictionary with the same behaviour. Martin Fowler's catalogue describes the pattern as mediating between the domain and data mapping layers with a collection-like interface, and Eric Evans made it a building block of domain-driven design.
Repository versus DAO
A data access object is shaped like the storage: one per table, with methods such as update_status(id, status). A repository is shaped like the domain: one per aggregate, with methods that load and store whole, consistent objects. If the order and its lines must change together to keep a rule true (the total equals the sum of the lines), they are one aggregate, and OrderRepository loads and saves both. Code that edits a line through its own repository can break the rule without the order ever knowing. Vaughn Vernon's aggregate design essays are the best guide to drawing these boundaries.
Unit of work and identity map
A use case often changes several aggregates, or loads the same one twice through different paths. Two companions handle that:
- Identity map. Within one unit of work, loading id 42 twice returns the same object, so two parts of the code cannot hold diverging copies.
- Unit of work. It remembers what was loaded, notices which objects changed, and on
commit()writes only those, in one transaction, or nothing at all.
SQLAlchemy's Session, Hibernate's Session and Entity Framework's DbContext are each an identity map plus a unit of work, and DbSet<T> is already a repository. Wrapping them in a hand-written generic repository adds a layer that does nothing. The useful repository over an ORM is narrow and domain-named: subscriptions.due_for_renewal(day), not repo.query(lambda s: ...).
Optimistic concurrency
Two support agents open the same customer account at once. One changes the email, the other the plan, and both save. Without protection the second save writes the old email back. The standard fix is a version number on each aggregate. Every save runs UPDATE accounts SET ..., version = version + 1 WHERE id = $1 AND version = $2; if no row matched, someone else saved first, and the repository raises a conflict instead of overwriting. The caller retries the whole use case from a fresh load, or shows the user a merge screen. Hibernate's @Version, EF Core concurrency tokens and SQLAlchemy's version_id_col all generate this statement for you.
Queries without a method explosion
Read-heavy screens push repositories toward find_by_status_and_region_and_created_after. Three ways out: a specification object that the adapter translates into a WHERE clause; a few intention-named methods for the queries the domain actually needs; or, for screens and reports, a separate read model that skips the repository and the domain objects entirely, as described in CQRS. Repositories are for loading aggregates to change them. They are a poor tool for lists of 500 rows with eight joined columns.
When not to use one
A CRUD admin screen with no rules beyond validation gains nothing from domain objects and repositories; let it talk to the ORM or the query builder directly. The pattern pays when there are invariants to protect and logic worth testing without a database.
Reviewing agent-written code
Ask an agent to "add a repository layer" and you often get BaseRepository<T> with get_all, get_by_id, create, update and delete for every table, over an ORM session. Look for: repositories per table rather than per aggregate, get_all() used and then filtered in Python, ORM objects or query builders leaking out of the interface, save methods that commit (so two saves in one use case are two transactions), and no version check. Ask for a narrow interface per aggregate, a unit of work that owns the transaction, and an in-memory implementation that passes the same contract tests as the real one.
The same shape elsewhere
A repository with versioned saves is the contention shape: many writers, one record, and a compare-and-set that lets exactly one win. Kubernetes objects carry a resourceVersion and the API server rejects an update with a stale one. Etcd transactions compare a key's mod_revision. HTTP uses ETag with If-Match and answers 412 Precondition Failed. Git refuses a non-fast-forward push. In every case the writer reads a version, proposes a change against it, and retries on conflict.
Down the stack
Layer 1The contract
get(id) returns the aggregate as last committed, or fails with not found; it never returns a half-loaded object. add fails on a duplicate identity. save succeeds only if the stored version equals the version the object was loaded with, and then increments it; otherwise it fails with a conflict and writes nothing. Objects returned are the caller's to modify, and modifications have no effect until saved and committed. A unit of work commits all of its changes or none, and returns the same object for the same id within its lifetime. An in-memory fake must honour all of this, or tests against it prove nothing; the Liskov Substitution Principle lesson shows how a shared contract suite enforces that.
Layer 2The mechanism
Hibernate snapshots every loaded entity's field values. At flush, it compares each managed entity with its snapshot (dirty checking), orders the INSERT, UPDATE and DELETE statements to respect foreign keys, and for versioned entities appends AND version = ? to each UPDATE. If the JDBC update count is 0, it throws OptimisticLockException. SQLAlchemy instruments mapped attributes so each assignment records the change in the object's state, keeps loaded objects in a weak-referencing identity map keyed by primary key, and on flush() topologically sorts the work by mapper dependencies before emitting SQL. With version_id_col it checks the matched row count and raises StaleDataError.
In Postgres, an UPDATE never changes a row in place: it writes a new tuple version and marks the old one dead. If no indexed column changed and the page has room, it is a heap-only tuple (HOT) update that skips index writes. The version column itself is not indexed, so bumping it keeps HOT updates possible.
Layer 3The machine
The repository's costs are round trips and flushes. A query inside one data centre costs about 0.2 to 1 ms of network and parsing before any work is done. Loading an order and then lazily loading each of its 50 lines is 51 round trips, 10 to 50 ms, where one join or one IN query costs 1 to 2 ms. That N+1 pattern is the classic repository failure, and it hides behind a clean interface.
Commit cost is dominated by the write-ahead log. Postgres writes the transaction's WAL records and, with synchronous_commit = on, waits for fsync (or fdatasync) on the WAL file before acknowledging. On a datacentre NVMe drive with power-loss protection that is tens of microseconds; on network-attached cloud volumes it is often 0.5 to 2 ms. A unit of work that commits once per use case pays this once. Code that commits inside each save pays it per aggregate and loses atomicity. In memory, identity maps and snapshots roughly double the memory of loaded entities, which matters when a batch job loads 100,000 of them into one session; clear the session in chunks.
Why it's built this way
- Business rules change more often than storage, and are more valuable to test, so they should not depend on SQL.
- Domain code thinks in whole objects with invariants, so persistence is offered as a collection of aggregates, not as tables.
- One use case can change several objects that must succeed or fail together, so changes are tracked and committed as one unit.
- Concurrent requests load the same aggregate, and locks held across user think time do not scale, so saves compare a version and fail fast.
- Round trips and fsyncs are the dominant costs, so loading is designed per aggregate and commits are batched per use case.
Diagram
sequenceDiagram
participant UC as Transfer use case
participant UoW as Unit of work
participant R as AccountRepository
participant DB as Postgres
UC->>UoW: get("a"), get("b")
UoW->>R: get("a"), get("b")
R->>DB: SELECT ... WHERE id IN ("a","b")
DB-->>R: rows (version 7, version 3)
R-->>UoW: Account a v7, Account b v3
UC->>UC: a.balance -= 30, b.balance += 30
UC->>UoW: commit()
UoW->>DB: BEGIN
UoW->>DB: UPDATE ... WHERE id = "a" AND version = 7
UoW->>DB: UPDATE ... WHERE id = "b" AND version = 3
DB-->>UoW: 1 row, 1 row
UoW->>DB: COMMITWorked example
A billing platform holds 2 million customer accounts. Support agents and an automated dunning job both edit accounts; at peak there are about 300 writes per second, and the same account is edited by two actors within a few seconds roughly 40 times a day. Before versioning, those collisions silently lost one of the two changes. Two customers a week reported a plan change that "didn't stick".
The team introduced AccountRepository with a version column and a unit of work per request. Each save became UPDATE ... WHERE id = $1 AND version = $2. Conflicts now surface as errors: the dunning job retries from a fresh load (up to 3 attempts, with jitter), and the support UI shows "this account changed while you were editing" with a diff. Over a month, 1,180 conflicts were caught, 1,150 resolved by automatic retry, and 30 needed an agent to re-apply a change. Lost-update reports dropped to zero.
The cost was small. Each update now carries one more predicate and writes one more integer column; p99 latency for the save moved from 4.1 to 4.3 ms. The bigger win came from the same refactor: the old code saved account, address and preferences in three transactions with three WAL flushes. The unit of work committed them in one, cutting the save path from 3 to 1 synchronous commits per request.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| SQL or ORM in the use case | Least code; full query power | Rules tangled with storage; slow tests | Thin CRUD with no real rules |
| Narrow repository per aggregate | Domain-named, testable with fakes; protects invariants | Mapping code; query methods to maintain | Domains with invariants and long life |
Generic Repository<T> over an ORM | Uniform API | Duplicates the ORM; leaks query concerns; per-table thinking | Rarely; prefer the ORM directly |
| Repository plus separate read model | Fast lists and reports; writes stay clean | Two models to keep in sync | Read-heavy screens over a rich domain |
Pessimistic locking (SELECT ... FOR UPDATE) | No conflicts to handle | Locks held for the transaction; deadlocks; no use across user think time | Short, hot, contended updates such as counters |
Pitfalls in production
save()that commits, so a use case with two saves can half-succeed. Fix: the unit of work owns the transaction; repositories never commit.- Lazy loading inside loops, turning one page load into hundreds of queries. Fix: load the aggregate with its parts in one query, and add a query-count assertion to tests.
- Missing version checks, so concurrent edits overwrite each other silently. Fix: a version column and a conditional update, with conflicts surfaced and retried at the use-case level.
- An in-memory fake that returns the stored object itself, so tests pass even when the code forgets to call
save. Fix: return copies, and run the same contract tests against the fake and the real adapter. get_all()followed by filtering in application code. Fix: an intention-named query method or a specification translated toWHERE.- Long-lived sessions in batch jobs growing without bound as the identity map fills. Fix: commit and clear in chunks of a few thousand.
Leadership lens
- Decide where repositories pay. Mandating them for every table creates a layer of empty wrappers; limiting them to aggregates with invariants keeps the cost where the value is.
- Treat lost updates as a correctness bug with a test, not a rare race to ignore. Ask in review: "what happens when two requests save this at once?"
- Require contract tests shared between the in-memory fake and the real adapter. Fast tests are only valuable if the fake is honest.
- Watch query counts per request in production. A repository interface hides N+1 patterns from code review, so measure them instead.
- Agents produce generic repository scaffolding by default. State the house style (aggregate repositories, unit of work owns commit, versioned saves) in the repo's agent instructions.
Check yourself
Why should
OrderLinenot have its own repository when lines belong to an order?Show answer
The order is the aggregate that protects rules spanning its lines, such as the total. Saving lines independently lets code change them without the order checking its invariants.
Two requests load account
aat version 7 and both save. What does a versioned repository do, and what should the caller do?Show answer
The first save matches
version = 7and bumps it to 8; the second matches no row and fails with a conflict. The caller reloads and retries the use case, or asks the user to resolve the difference.Why should an in-memory fake repository return copies rather than the stored objects?
Show answer
Real repositories return detached data; returning the stored object lets a test pass even when the code never calls
save, so the fake hides bugs.When would you skip the repository for a query?
Show answer
For read-only lists and reports, where a dedicated read query or read model is faster and nothing is being changed, so no invariants need the domain object.
Watch
- Deep Dive Into the Repository Design Pattern in Python (ArjanCodes): building a repository interface and swapping storage implementations behind it.
Free resources
- Repository (Martin Fowler, PoEAA catalog): the original short definition of the pattern.
- Unit of Work (Martin Fowler, PoEAA catalog): the companion pattern that tracks changes and commits them together.
- Repository Pattern (Architecture Patterns with Python): a full, free chapter with fakes, ports and tests.
- Unit of Work Pattern (Architecture Patterns with Python): how the unit of work owns the transaction in practice.
- Effective Aggregate Design, Part I (Vaughn Vernon): how to size aggregates, which decides what each repository loads.
- Session Basics (SQLAlchemy docs): a production identity map and unit of work, and when to flush and commit.