Skip to main content
ANVISoftware Solutions
Lesson 8 of 19Advanced20 min

Repository Pattern

By the end of this lesson

Abstract data access, and decide honestly whether you need to.

A repository is an object that behaves like a collection of domain objects and hides where they are actually stored. You ask it for an invoice; it does not tell you whether that involved SQL, a cache or a web service.

The pattern predates modern object-relational mappers, and that history matters. It was written when data access meant hand-built SQL and manual mapping, so wrapping that work behind a collection-like interface was a substantial gain. Some of that gain is now provided by the ORM itself, which changes the calculation rather than settling it.

A repository that adds a layer and hides nothing
C#
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(int id);
    Task<List<Order>> GetAllAsync();
    Task AddAsync(Order order);
    Task SaveChangesAsync();
}

public sealed class OrderRepository(OrdersDbContext db) : IOrderRepository
{
    public Task<Order?> GetByIdAsync(int id) => db.Orders.FindAsync(id).AsTask();

    public Task<List<Order>> GetAllAsync() => db.Orders.ToListAsync();

    public Task AddAsync(Order order)
    {
        db.Orders.Add(order);
        return Task.CompletedTask;
    }

    public Task SaveChangesAsync() => db.SaveChangesAsync();
}
  • Every method forwards one call. DbSet already offers FindAsync, ToListAsync and Add, and DbContext.SaveChangesAsync already commits one transaction, so nothing here is new capability.
  • What it did add: an interface, an implementation, a registration line, and a fake for every test that touches orders. Four things to maintain in exchange for the same behaviour.
  • GetAllAsync is worse than what it replaced. Through the DbSet a caller could filter, sort and page in the database. Through this method the whole table comes back and filtering happens in memory — on a table with a million orders that is a production incident waiting for a slow week.
  • AddAsync is not asynchronous. Returning Task.CompletedTask to fit the interface is a sign the abstraction was shaped by habit rather than by what the caller needs.

With EF Core there is a specific reason this happens. DbContext is already a unit of work: it tracks the changes you make and commits them together when you call SaveChangesAsync. DbSet is already a queryable abstraction over a collection of entities. A repository that only forwards to them is duplicating an abstraction you already have.

That is not an argument against repositories. It is an argument against repositories with no job. A repository earns its place when it does something the DbSet does not — and there are three things worth doing.

A repository doing work the DbSet does not
C#
public interface IInvoiceRepository
{
    Task<Invoice?> FindAsync(int id, CancellationToken token);
    Task<IReadOnlyList<Invoice>> UnpaidForCustomerAsync(int customerId, CancellationToken token);
    void Add(Invoice invoice);
}

internal sealed class EfInvoiceRepository(InvoicingDbContext db, ITenantContext tenant)
    : IInvoiceRepository
{
    // Every query starts here, so neither filter can be forgotten.
    private IQueryable<Invoice> Visible =>
        db.Invoices.Where(invoice => invoice.TenantId == tenant.Id && !invoice.IsArchived);

    public async Task<Invoice?> FindAsync(int id, CancellationToken token) =>
        await Visible
            .Include(invoice => invoice.Lines)
            .FirstOrDefaultAsync(invoice => invoice.Id == id, token);

    public async Task<IReadOnlyList<Invoice>> UnpaidForCustomerAsync(
        int customerId, CancellationToken token) =>
        await Visible
            .Where(invoice => invoice.CustomerId == customerId && invoice.Status != InvoiceStatus.Paid)
            .OrderBy(invoice => invoice.DueUtc)
            .ToListAsync(token);

    public void Add(Invoice invoice) => db.Invoices.Add(invoice);
}
  • The tenant filter and the archive filter are applied in one place. A developer adding a query next year cannot forget them, because there is no route to the table that skips them. Getting that wrong means showing one customer another customer's invoices, so a single enforced location is worth real money.
  • Method names state intent — unpaid invoices for a customer — rather than exposing a query language. A reader of the calling code learns what is being asked for, not how.
  • Results come back as IReadOnlyList, and only loaded invoices come with their lines. Callers cannot accidentally leave a query open and trigger further database work later.
  • There is no SaveChangesAsync here. The use case owns the transaction boundary, so it calls SaveChangesAsync once after all the work, and two repository operations commit together.
  • Worth knowing: EF Core can apply the tenant and archive filters itself through a global query filter configured on the model. If that is all you need, the filter is a few lines in your context configuration and no repository is required.

Deciding honestly, for a typical use case in an invoicing application:

 DbContext used directlyRepository in front of it
Files to maintainOne handlerHandler, interface, implementation, registration, test fake
Where query rules liveIn each query; a missed filter is a data leakIn one place that cannot be bypassed
Inner projects and ORM typesInner projects reference the ORMInner projects stay free of it
Testing the logicTest database, or the ORM's test providerA hand-written fake
Testing the queryCovered, because the real query runsNot covered by fakes; needs its own test
Using ORM featuresAvailable immediatelyEach one needs a method on the interface

Summary

  • A repository hides where data lives behind a collection-like, intent-named interface
  • EF Core already supplies a unit of work and a queryable abstraction, so a forwarding wrapper adds files without capability
  • It earns its place by swapping data sources, enforcing query rules centrally, or keeping ORM types out of inner projects
  • Repositories belong to aggregates, and the transaction boundary belongs to the use case
  • Faked repositories test logic, not queries — cover queries against a real database engine

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Fourteen repositories

A team proposes a generic Repository class and an interface for each of the application's fourteen entities, on the grounds that data access should always be abstracted.

What three questions would you ask before agreeing, and what would you propose instead if the answers are unremarkable?

Show solution

First: which of the fourteen have a query rule that must never be missed? Those are the ones with a real case. Tenant isolation and soft deletion usually cover a handful, not all fourteen.

Second: are any of these entities aggregates with rules, or are they lookup tables? A repository over a list of countries is a wrapper around a read.

Third: what will the tests use? If the answer is faked repositories, the queries themselves are untested, and queries are where a lot of production defects come from.

A proportionate proposal: repositories for the two or three aggregates that have rules and centralised filters, the context used directly elsewhere, and integration tests against a real database engine for the queries that matter. That is less code and more coverage than fourteen abstractions.

Worth naming the counter-argument fairly. Consistency has value — a codebase where data access is reached the same way everywhere is easier to learn than one with two conventions. If the team prefers uniformity, choose it deliberately with the cost understood, rather than because the pattern has a name.

Try it yourself

Fix a leaky interface

Rewrite this interface so it no longer leaks the query language, keeping the two things the callers actually do: fetch one employee with their current contract, and list employees in a department who are not archived.

The interface is: IQueryable<Employee> Query(); and Task<List<Employee>> Where(Expression<Func<Employee, bool>> predicate);

Show solution

Two intent-named methods replace both. The caller asks for what it wants; the repository decides how, including which related data to load.

The gain is not tidiness. With the expression-based version, any caller can write a filter the ORM cannot translate, and the exception arrives at run time in production. With named methods, every query is written once, next to the model it queries, and can be reviewed and tested there.

The cost is real and worth stating: a new query means a new method rather than a filter written at the call site. On a fast-moving feature that friction is felt. Some teams accept the expression-based version for read-only reporting queries and keep the named methods for anything that writes — a defensible split, as long as it is a decision rather than a drift.

C#
public interface IEmployeeRepository
{
    Task<Employee?> FindWithCurrentContractAsync(int employeeId, CancellationToken token);

    Task<IReadOnlyList<Employee>> ActiveInDepartmentAsync(int departmentId, CancellationToken token);
}

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Why does wrapping EF Core's DbContext in a forwarding repository add little?
Which of these is the strongest reason to introduce a repository?

Saved in this browser only.