The repository pattern, and the argument against it

"Explain the repository pattern. Now argue against using it here."

What it is

A repository is an abstraction that presents a collection-like interface for accessing domain objects, mediating between the domain layer and the data mapping layer. Fowler's definition in Patterns of Enterprise Application Architecture is precise about that last part: the repository's job is to make persistence look like an in-memory collection to the domain.

class OrderRepository(Protocol):
    def get(self, order_id: OrderId) -> Order | None: ...
    def add(self, order: Order) -> None: ...
    def find_by_customer(self, customer_id: CustomerId) -> list[Order]: ...

The domain code asks for orders. It does not know whether they come from Postgres, DynamoDB, an HTTP API or a dictionary in a test.

Three related patterns travel with it and are commonly conflated:

PatternResponsibility
RepositoryCollection-like access to aggregates
Unit of WorkTracks changes across a business transaction, commits or rolls back atomically
SpecificationA composable, testable predicate that can be evaluated in memory and translated to a query

Commonly confused with a data access object. A DAO is table-oriented and exposes CRUD per table; a repository is aggregate-oriented and exposes domain operations. The distinction matters because a "repository" with updateOrderStatus(id, status) on it is a DAO wearing the name, and it provides none of the benefit.

Also commonly confused with "the thing that wraps the ORM". If your ORM already gives you session.query(Order), wrapping it in a class named OrderRepository that forwards each call adds a file and changes nothing.

The problem it solves

Three genuine problems, and it is worth being precise because two of them are commonly claimed and rarely real.

1. The domain layer depending on persistence details. Without a repository, domain logic contains SQL, ORM session handling, or SDK calls. That makes the domain hard to read (business rules interleaved with query construction), hard to test (needs a database), and hard to change.

2. Testability. An in-memory repository lets you test business logic with no database, no fixtures, no transaction rollback ceremony. Tests go from hundreds of milliseconds to microseconds, which changes how many you write.

class InMemoryOrderRepository:
    def __init__(self) -> None:
        self._orders: dict[OrderId, Order] = {}

    def get(self, order_id): return self._orders.get(order_id)
    def add(self, order): self._orders[order.id] = order
    def find_by_customer(self, cid):
        return [o for o in self._orders.values() if o.customer_id == cid]

3. Enforcing aggregate boundaries. This is the one that is genuinely valuable and least discussed. If the only way to load an Order is OrderRepository.get(), then loading an order always loads it consistently, with its line items, in one transaction. Without that constraint, code all over the system constructs partial orders with whatever joins the author needed, and invariants stop being enforceable.

The claimed benefit that is usually false: "we can swap the database." Nobody swaps Postgres for MongoDB behind an interface, and if they did, the repository interface would leak anyway, because the query shapes, the transaction semantics and the consistency model all differ. Presenting swappability as the justification is the weakest version of the argument and an interviewer may be probing for it.

Mechanics

A repository with Unit of Work

class SqlOrderRepository:
    def __init__(self, session: Session) -> None:
        self._session = session

    def get(self, order_id: OrderId) -> Order | None:
        # selectinload keeps the aggregate whole: an Order without its
        # items is not a valid Order, so loading one is not optional.
        return (
            self._session.query(Order)
            .options(selectinload(Order.items))
            .filter(Order.id == order_id)
            .one_or_none()
        )

    def add(self, order: Order) -> None:
        self._session.add(order)


class UnitOfWork:
    """One business transaction. Repositories share a session, so
    changes across aggregates commit or roll back together."""

    def __enter__(self) -> "UnitOfWork":
        self._session = session_factory()
        self.orders = SqlOrderRepository(self._session)
        self.payments = SqlPaymentRepository(self._session)
        return self

    def __exit__(self, exc_type, *_) -> None:
        if exc_type:
            self._session.rollback()
        else:
            self._session.commit()
        self._session.close()
# Application service: reads as business logic, not as data access.
def cancel_order(order_id: OrderId, reason: str) -> None:
    with UnitOfWork() as uow:
        order = uow.orders.get(order_id)
        if order is None:
            raise OrderNotFound(order_id)
        order.cancel(reason)                     # domain invariants live here
        uow.payments.add(order.build_refund())   # same transaction

Unit of Work is what makes the repository honest. Without it, each repository owns its own transaction and a two-aggregate operation cannot be atomic, which is a worse problem than the one the repository solved.

Specification, for the query-explosion problem

The classic failure is a repository that accretes methods:

find_by_customer(cid)
find_by_customer_and_status(cid, status)
find_by_customer_and_status_and_date_range(cid, status, start, end)
find_by_status_and_total_greater_than(status, amount)
# ... 40 more

Specification composes predicates instead:

spec = (
    CustomerSpec(customer_id)
    & StatusSpec(OrderStatus.PENDING)
    & PlacedAfterSpec(cutoff)
)
orders = uow.orders.find(spec)

The property that makes it more than syntax: a specification can be evaluated in memory (spec.is_satisfied_by(order)) and translated to SQL, so the same rule used to filter a query is the rule used to validate a single object. That is genuinely useful for business rules that appear in both places.

The cost is that you are building a query language, and translating an arbitrary specification tree into efficient SQL is hard. Most teams that adopt Specification end up with a subset that works and a set of hand-written queries for everything else, at which point the abstraction is paying rent on a small fraction of the code.

The argument against it, here

The interview question is the second half, and it is the part that separates a pattern-recogniser from an engineer. Six arguments, ordered by how often they are the deciding one.

1. Your ORM is already a repository, and a better one. SQLAlchemy's Session and Entity Framework's DbContext are Unit of Work implementations, and DbSet<T> is a repository. Wrapping them produces a layer that forwards calls, and the team maintains it forever. This is Fowler's own framing: the pattern describes what an ORM does, so implementing it on top of an ORM is often duplicating it.

2. It leaks anyway, and the leaks are the important part. The interface says find_by_customer(cid). It does not say whether that returns 5 rows or 5 million, whether it is indexed, whether it N+1s, or whether it holds a lock. Those are the properties that determine whether your system works, and the abstraction is silent on all of them. A leaky abstraction over the single most performance-critical layer is a bad trade, and the leak shows up as production incidents rather than as compile errors.

3. It obstructs the database's actual capabilities. Window functions, CTEs, INSERT ... ON CONFLICT, partial indexes, FOR UPDATE SKIP LOCKED, full-text search, JSONB operators. Every one of those either bypasses the repository or forces a method so specific that it is a query with a name. Teams then add execute_raw_sql to the repository and the abstraction has formally surrendered.

4. The in-memory fake diverges from the real implementation. Your InMemoryOrderRepository uses Python equality; Postgres uses collation. It has no transaction isolation, no unique constraints, no cascade behaviour, no null-ordering semantics. Tests pass against the fake and fail against the database, which is the worst outcome: a test suite that is fast, green and not telling you the truth. Testcontainers has largely removed the argument that a real database is too slow for unit tests.

5. CQRS makes it unnecessary on the read side. Repositories are about aggregates and invariants, which are write-side concerns. A read that produces a screen wants a denormalised projection, and forcing it through an aggregate-shaped repository produces either N+1 queries or a repository method per screen. Split reads and writes and the repository question mostly disappears for reads, which is typically the large majority of the code. See CQRS: the adoption ladder.

6. In a service that is mostly CRUD, it is pure overhead. If the domain has no invariants worth protecting, the repository protects nothing and costs a file per entity plus an interface plus a fake.

A worked example

Two services in the same company, and the same question gets opposite answers.

Service A: order management. Rich invariants (an order cannot be cancelled after shipping; a refund cannot exceed the captured amount; line items must sum to the total), multi-aggregate transactions, and business rules that change every quarter.

Repository + Unit of Work: YES

Why:  the aggregate boundary is load-bearing. If any code can load an
      Order without its items, `order.cancel()` cannot enforce its
      invariants, and we have already had one incident from exactly
      that. Unit of Work makes order-plus-payment atomic.

Cost: 2 interfaces, 2 SQL implementations, 2 in-memory fakes,
      about 400 lines.
Benefit: the domain test suite runs in 0.8 s instead of 41 s, so it
      runs on every save, and the invariant logic is testable without
      a database at all.

Service B: reporting and analytics API. No writes, no invariants, 30 endpoints each producing a different shaped result, heavy use of window functions and CTEs.

Repository: NO

Why:  there are no aggregates and no invariants, so there is nothing
      for the pattern to protect. Every endpoint is a distinct query
      shape, so the repository would be 30 methods that each wrap one
      query, which is a naming layer rather than an abstraction.
      Half the queries need window functions the interface cannot
      express, so they would bypass it anyway.

Instead: query objects, one per endpoint, each holding its SQL and its
      row mapper, tested against a real Postgres via Testcontainers.
      Same testability story, no abstraction tax, full access to the
      database.

The reasoning that generalises, and the thing to say in an interview: the repository pattern earns its cost where aggregate boundaries and invariants are load-bearing. Where the code is queries producing shapes, a query object per use case is simpler, more honest about what it does, and does not obstruct the database.

And the hybrid that is usually correct in one codebase: repositories on the write side where invariants live, query objects on the read side where they do not. That is CQRS level 1 and it costs almost nothing.

Production evidence

Fowler's Patterns of Enterprise Application Architecture (2002) defines Repository, Unit of Work and Data Mapper, and is explicit that Repository sits on top of a mapping layer rather than replacing one.

Eric Evans's Domain-Driven Design (2003) ties repositories to aggregates specifically: one repository per aggregate root, not per table. Most misuse of the pattern is a violation of that single rule.

Entity Framework's DbContext/DbSet and SQLAlchemy's Session are documented implementations of Unit of Work and Repository respectively. Microsoft's own architecture guidance has, over successive versions, moved from recommending a repository layer over EF to noting that DbContext already is one and that the extra layer is often unnecessary.

Percival and Gregory, Architecture Patterns with Python (2020) is the most practically useful treatment, because it builds repository, Unit of Work and service layer incrementally and is candid about when each stops paying for itself.

Testcontainers materially changed this debate: the historical argument for in-memory fakes was that real databases were too slow for unit tests, and a Postgres container that starts in a couple of seconds and is reused across a suite largely removes it.

The debate

The case for: aggregate boundaries and invariants are the hardest thing to enforce in a growing codebase, and a repository is the mechanism that enforces them. Fast domain tests change how much testing gets written. And the domain layer stays readable, which matters more the longer the system lives.

The case against: your ORM already implements the pattern; the abstraction leaks on exactly the properties that matter (cardinality, indexes, locking, N+1); it obstructs database features you are paying for; in-memory fakes lie; and on the read side, which is most code, it provides nothing.

My position: use repositories for aggregates with real invariants, use query objects for reads, and never wrap an ORM in a repository that only forwards calls. The question I ask is: is there an invariant that would be violated if code could load a partial version of this thing? If yes, the repository is enforcing something real. If no, it is a file.

Two specific commitments beyond that. Unit of Work comes with the repository or neither does, because per-repository transactions make multi-aggregate operations non-atomic, which is worse than the problem being solved. And test against a real database via Testcontainers regardless, using in-memory fakes only for domain logic that genuinely touches no persistence semantics, because a fake that diverges from Postgres produces a green suite that is not telling the truth.

Where I would push back hardest is a codebase with IRepository<T> as a generic base class exposing GetAll, Find, Update and Delete for every entity. That is a DAO with a fashionable name: it has no aggregate boundaries, it encourages loading whole tables, and it delivers none of the pattern's benefit while charging its full price.

Follow-up Q&A

"Explain the repository pattern." It presents a collection-like interface over persistence so the domain layer can ask for aggregates without knowing where they come from. The important word is aggregate: one repository per aggregate root, not one per table. That distinction is what separates it from a DAO, and it is what makes it valuable, because if the only way to load an Order is through the repository then an Order is always loaded whole and its invariants remain enforceable.

"Now argue against it here." Six arguments. Your ORM already implements it, so wrapping Session or DbContext produces a forwarding layer you maintain forever. It leaks on exactly the properties that matter, since the interface says nothing about cardinality, indexes, locking or N+1, and those decide whether the system works. It obstructs window functions, CTEs, upserts and SKIP LOCKED, so teams add execute_raw_sql and the abstraction formally surrenders. The in-memory fake diverges from the real database on collation, isolation and constraints, so tests are fast, green and wrong. On the read side there are no invariants to protect. And in a CRUD service there is nothing to protect at all.

"So when is it right?" When there is an invariant that would be violated if code could load a partial version of the thing. Order with line items and a cancellation rule: yes, and I have seen an incident caused precisely by code loading an order without its items. A reporting endpoint producing a screen-shaped result: no, that is a query object. The test is whether the aggregate boundary is load-bearing, not whether the codebase is "clean architecture".

"What about testing without a database?" That was the strongest argument for in-memory fakes and Testcontainers largely removed it: a Postgres container starting in a couple of seconds and reused across the suite gives you real collation, real constraints, real isolation. I would keep in-memory fakes only for domain logic that touches no persistence semantics at all, and run everything else against the real engine, because a fake that diverges produces a green suite that is lying.

"Where does Unit of Work fit?" It is not optional if you have repositories. Without it each repository owns its own transaction, so an operation touching two aggregates cannot be atomic, and you have created a worse problem than the one you solved. In practice Unit of Work owns the session, the repositories share it, and the block commits or rolls back as a whole.

"What's wrong with a generic IRepository<T>?" It is a DAO with a fashionable name. Generic GetAll, Find, Update and Delete for every entity means there are no aggregate boundaries, which is the entire value of the pattern. It also encourages loading whole tables and hides query cost behind a uniform interface. If I see it, I would rather have the ORM directly, because at least then the cost is visible.

"Does CQRS change the answer?" Substantially. Repositories are a write-side concept, because aggregates and invariants are write-side concerns. A read that produces a screen wants a denormalised projection, and forcing it through an aggregate-shaped repository yields either N+1 queries or one method per screen. Splitting reads from writes, even at the simplest level where both hit the same database, makes the repository question disappear for the majority of the code.

Common misconceptions

"One repository per table." One per aggregate root. Per-table is a DAO and gives up the boundary that made the pattern worth having.

"It lets us swap databases." Nobody does this, and the interface would leak if they tried, because query shapes, transaction semantics and consistency models differ.

"A repository is required by clean architecture." Clean architecture requires a dependency direction. A query object satisfies it equally well.

"The in-memory fake is equivalent to the real thing." It has no isolation, no constraints, no collation, no cascades. It is fast and it is a different system.

"Adding it is always the safe choice." A layer that forwards calls is a permanent maintenance cost and a permanent indirection for every reader of the code.

Interview delivery note

Define it in terms of aggregates, because that is what separates it from a DAO and it is the first thing being scored: "It presents a collection-like interface over persistence so the domain can ask for aggregates without knowing where they live. The important word is aggregate, one per aggregate root rather than one per table, because the value is that if the only way to load an Order is through the repository, an Order is always loaded whole and its invariants stay enforceable."

Then take the second half seriously, since that is the actual question: "Against it here: my ORM already is one. SQLAlchemy's Session is a Unit of Work and its query interface is a repository, so wrapping it produces a forwarding layer we maintain forever. And it leaks on exactly the properties that matter. The interface says find_by_customer, and it doesn't say whether that's five rows or five million, whether it's indexed, or whether it N+1s. Those decide whether the system works."

Then commit to a rule, because an answer that lists trade-offs without landing is a weak one: "So my test is whether there's an invariant that would be violated if code could load a partial version of the thing. Order with line items and a cancellation rule, yes. A reporting endpoint producing a screen, no, that's a query object. And in one codebase I'd usually have both: repositories on the write side where the invariants are, query objects on the read side where they aren't."

The line that shows you have maintained one of these: "and if I see a generic IRepository<T> with GetAll, Find, Update and Delete for every entity, that's a DAO with a fashionable name. It has no aggregate boundaries, so it charges the full price of the pattern and delivers none of it."

Further reading

  • Martin Fowler, Patterns of Enterprise Application Architecture, on Repository, Unit of Work and Data Mapper.
  • Eric Evans, Domain-Driven Design, chapter 6, for the one-per-aggregate-root rule.
  • Percival and Gregory, Architecture Patterns with Python (2020), chapters 2 and 6, which build the pattern up and are candid about when it stops paying.
  • Microsoft's .NET application architecture guidance on DbContext as Unit of Work, for the vendor's own framing of the wrap-the-ORM question.
  • The Testcontainers documentation, for why the in-memory-fake argument has weakened.