1. The Problem Context — When the Pattern Stops Helping

Every .NET codebase reaches a point where someone wraps DbContext in a generic IRepository<T>. The intent is honourable — abstract the persistence layer for “testability” and “swap-ability”. The result is usually less testable, never swapped, and silently slower. Six months later, a new developer asks why a list endpoint pulls back the whole table and computes the projection in memory. The answer: the repository returned IEnumerable<T> and execution materialised at the boundary.

🤔 Sound familiar?
If your IRepository<T> exposes IQueryable<T>, you haven't abstracted EF Core — you've coupled to it through a longer pipe. If it exposes IEnumerable<T>, every filter and projection runs client-side. Either way, the pattern is paying negative dividends.

The Repository pattern (Fowler, PoEAA, 2002) was defined for a world without ORMs. EF Core's DbContext is already a Unit of Work, and each DbSet<T> is already an in-memory collection façade backed by storage. Adding a layer on top has to earnits existence. This article is about when it does and when it doesn't.

2. The Concept Explanation — What Repository Really Solves

Strip the pattern back to its purpose: keep domain code free of persistence concerns. That is a real problem worth solving — but only when the domain code has persistence concerns leaking in. A controller that calls db.Orders.Where(...)doesn't have leakage; a domain service that has to know about SaveChangesAsync ordering does.

flowchart TB
  subgraph Anti["Anti-pattern: Generic Repository"]
    A1[IRepository&lt;T&gt;.GetAll]
    A2[IRepository&lt;T&gt;.Find]
    A3[IRepository&lt;T&gt;.Add/Update/Delete]
    A1 --&gt; A4[Service does projection client-side]
    A2 --&gt; A4
  end
  subgraph Useful["Useful: Domain Repository"]
    B1[IOrderRepository.FindOpenOrdersForCustomer]
    B2[IOrderRepository.SaveAsync]
    B1 --&gt; B3[Hand-written EF query, intent-revealing]
    B2 --&gt; B4[Wraps SaveChanges + outbox]
  end
  subgraph Spec["Useful: Specification"]
    C1[Specification&lt;Order&gt;.Where]
    C2[OrderRepo.Query&lt;TResult&gt;(spec, projector)]
    C1 --&gt; C2
  end

The three sane shapes:

  • No repository. Inject DbContextdirectly into application services. Default for small/medium services — the “you aren't gonna need it” option.
  • Domain repository. One interface per aggregate root (e.g. IOrderRepository) with intent-revealing methods (FindWithLines, FindOpenOrdersForCustomer). No IQueryable escapes.
  • Specification pattern. Encapsulate query criteria as objects so they can be unit-tested, composed, and reused. Pair with a thin repo that accepts a spec.

3. The Implementation — Domain Repository + Specification

3.1 The domain repository

One interface per aggregate, named with the verbs your domain actually uses. The implementation lives in the infrastructure project and is the only place that touches DbContext.

// Domain layer — depends on nothing
public interface IOrderRepository
{
    Task&lt;Order?&gt; FindAsync(OrderId id, CancellationToken ct);
    Task&lt;IReadOnlyList&lt;Order&gt;&gt; FindOpenOrdersForCustomer(CustomerId customer, CancellationToken ct);
    Task AddAsync(Order order, CancellationToken ct);
}

// Infrastructure layer — depends on EF Core
internal sealed class OrderRepository(AppDb db) : IOrderRepository
{
    public Task&lt;Order?&gt; FindAsync(OrderId id, CancellationToken ct) =&gt;
        db.Orders
          .Include(o =&gt; o.Lines)
          .SingleOrDefaultAsync(o =&gt; o.Id == id, ct);

    public async Task&lt;IReadOnlyList&lt;Order&gt;&gt; FindOpenOrdersForCustomer(CustomerId customer, CancellationToken ct) =&gt;
        await db.Orders
            .Where(o =&gt; o.CustomerId == customer &amp;&amp; o.Status == OrderStatus.Open)
            .OrderByDescending(o =&gt; o.PlacedAt)
            .ToListAsync(ct);

    public async Task AddAsync(Order order, CancellationToken ct) =&gt;
        await db.Orders.AddAsync(order, ct);
}

Notice what the repository does not have: SaveChangesAsync. That belongs to a Unit-of-Work boundary the application service controls — typically a single SaveChangesAsync call at the end of a use case, often paired with an outbox/inbox.

3.2 The specification pattern, when criteria proliferate

When a single aggregate has many filter combinations (search, paging, multi-tenant scoping, soft-delete, security trim), specifications keep the repo small.

public abstract class Specification&lt;T&gt;
{
    public Expression&lt;Func&lt;T, bool&gt;&gt;? Criteria { get; protected init; }
    public List&lt;Expression&lt;Func&lt;T, object&gt;&gt;&gt; Includes { get; } = [];
    public int? Take { get; protected init; }
    public int? Skip { get; protected init; }
}

public sealed class OpenOrdersForCustomer : Specification&lt;Order&gt;
{
    public OpenOrdersForCustomer(CustomerId id, int page, int size)
    {
        Criteria = o =&gt; o.CustomerId == id &amp;&amp; o.Status == OrderStatus.Open;
        Skip = page * size;
        Take = size;
    }
}

public interface IRepository&lt;T&gt; where T : class
{
    Task&lt;IReadOnlyList&lt;TResult&gt;&gt; QueryAsync&lt;TResult&gt;(
        Specification&lt;T&gt; spec,
        Expression&lt;Func&lt;T, TResult&gt;&gt; projector,
        CancellationToken ct);
}

internal sealed class EfRepository&lt;T&gt;(AppDb db) : IRepository&lt;T&gt; where T : class
{
    public async Task&lt;IReadOnlyList&lt;TResult&gt;&gt; QueryAsync&lt;TResult&gt;(
        Specification&lt;T&gt; spec,
        Expression&lt;Func&lt;T, TResult&gt;&gt; projector,
        CancellationToken ct)
    {
        IQueryable&lt;T&gt; q = db.Set&lt;T&gt;().AsNoTracking();
        if (spec.Criteria is not null) q = q.Where(spec.Criteria);
        foreach (var inc in spec.Includes) q = q.Include(inc);
        if (spec.Skip is { } s)        q = q.Skip(s);
        if (spec.Take is { } t)        q = q.Take(t);
        return await q.Select(projector).ToListAsync(ct);
    }
}

Specifications are unit-testable in isolation (just compile the expression and run it against an in-memory list), composable through And/Or combinators, and the repo stays a one-method affair. Crucially, the projector is supplied per-call so every read path stays projection-native — no entity hydration unless the caller asks.

3.3 Unit-of-Work — usually unnecessary, sometimes essential

EF Core's DbContext already coordinates a transaction-scoped change set, so a hand-rolled IUnitOfWork typically reduces to a thin facade over SaveChangesAsync. It earns its place when the transaction must span multiple contexts, span an outbox publish, or remain controllable from a use-case orchestrator.

public interface IUnitOfWork { Task&lt;int&gt; CommitAsync(CancellationToken ct); }

internal sealed class EfUnitOfWork(AppDb db, IOutbox outbox) : IUnitOfWork
{
    public async Task&lt;int&gt; CommitAsync(CancellationToken ct)
    {
        await using var tx = await db.Database.BeginTransactionAsync(ct);
        var changes = await db.SaveChangesAsync(ct);
        await outbox.FlushAsync(ct);              // same transaction
        await tx.CommitAsync(ct);
        return changes;
    }
}

4. Pitfalls That Quietly Kill Throughput

  • Generic IRepository<T>.GetAll. If it returns IEnumerable<T>, every later .Where runs client-side. If it returns IQueryable<T>, you've leaked EF Core through your “abstraction”. Either way the pattern provides nothing but ceremony.
  • One repository per entity. Repositories belong to aggregate roots, not to every table. If OrderLine only has meaning inside an Order, there is no IOrderLineRepository.
  • Mocking IRepository<T> for unit tests. The cost is low only because the test is now decoupled from reality. EF Core's in-memory provider lies about transactions, concurrency, and SQL behavior. Prefer Testcontainers with the real database for repository tests; reserve mocks for layers above.
  • Hiding SaveChangesAsync inside the repo. Now multiple repository calls each commit independently and you lose atomicity. The use-case is the right transactional boundary; expose UoW or call SaveChangesAsync in the application service.
  • Repository as DTO mapper. Returning DTOs from the repo couples the persistence layer to presentation needs. Project at the use-case layer where the contract lives.
  • The fictional “swap to MongoDB” argument.Nobody swaps databases via interface change. The data model, query patterns, transactional guarantees and operational tooling all differ. Don't pay the abstraction cost for a refactor that will never happen.

5. Practical Takeaways

  • Default to no repository. Inject DbContext into application services. Add a repository when domain code starts learning persistence concerns.
  • When you do add one, make it a domain repository per aggregate root with intent-revealing methods. Never expose IQueryable.
  • Reach for specifications when filter combinations explode. Always pass a projector so reads stay column-trim.
  • Keep SaveChangesAsync at the use-case boundary. Wrap in a Unit-of-Work only when you need to coordinate side-effects (outbox, multiple contexts).
  • Test repositories against the real database with Testcontainers. Save mocking for the layers above.
  • The best repository is often the one you didn't write. Pattern adoption is a cost; require it to clear a real bar of value first.