Engineering Atlas › Low-Level Design

Repository: a collection-like data access layer

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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:

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

  1. Business rules change more often than storage, and are more valuable to test, so they should not depend on SQL.
  2. Domain code thinks in whole objects with invariants, so persistence is offered as a collection of aggregates, not as tables.
  3. One use case can change several objects that must succeed or fail together, so changes are tracked and committed as one unit.
  4. Concurrent requests load the same aggregate, and locks held across user think time do not scale, so saves compare a version and fail fast.
  5. 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: COMMIT

Worked 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

OptionStrengthsWeaknessesChoose when
SQL or ORM in the use caseLeast code; full query powerRules tangled with storage; slow testsThin CRUD with no real rules
Narrow repository per aggregateDomain-named, testable with fakes; protects invariantsMapping code; query methods to maintainDomains with invariants and long life
Generic Repository<T> over an ORMUniform APIDuplicates the ORM; leaks query concerns; per-table thinkingRarely; prefer the ORM directly
Repository plus separate read modelFast lists and reports; writes stay cleanTwo models to keep in syncRead-heavy screens over a rich domain
Pessimistic locking (SELECT ... FOR UPDATE)No conflicts to handleLocks held for the transaction; deadlocks; no use across user think timeShort, hot, contended updates such as counters

Pitfalls in production

Leadership lens

Check yourself

  1. Why should OrderLine not 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.

  2. Two requests load account a at version 7 and both save. What does a versioned repository do, and what should the caller do?

    Show answer

    The first save matches version = 7 and 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.

  3. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →