Repository pattern in .NET

Let me be honest with you.

The Repository pattern is one of those topics where the loudest voices give the worst advice.

"Always use it." Too rigid.

"Never use it." Too lazy.

The truth โ€” and the one Microsoft Learn quietly confirms โ€” sits in the middle. In this short read, I want to share how I think about it in real projects, with EF Core, with Dapper, and what the official guidance actually says.

๐Ÿงญ What the Repository Pattern Really Is

A Repository is an abstraction over a collection of domain objects. It lets your application code work with Orders or Customers without knowing whether they come from SQL Server, Cosmos DB, or an in-memory list.

That's it.

Its two main jobs:

  • โœ… Decouple your domain and application layers from the data store
  • โœ… Make testing easier by allowing the data access layer to be stubbed or mocked

The Unit of Work pattern usually rides along with it: it groups multiple changes into a single transaction.

๐Ÿงฉ The EF Core Twist

Here is the part many developers miss.

DbContext already implements Repository and Unit of Work.

This is not my opinion. It is what Microsoft Learn explicitly states in the EF Core advanced tutorial and the DDD/CQRS architecture guide:

  • DbSet<T> behaves like a repository.
  • SaveChanges is the Unit of Work commit.
  • The change tracker handles the dirty-checking for you.

So when you wrap EF Core in a generic IRepository<T> that exposes GetById, Add, Update, Delete, and SaveChanges... you've just rebuilt DbContext with extra steps and a worse API.

โŒ The Anti-Pattern I See Too Often

public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id, CancellationToken ct);
    Task<IEnumerable<T>> GetAllAsync(CancellationToken ct);
    Task AddAsync(T entity, CancellationToken ct);
    Task SaveChangesAsync(CancellationToken ct);
}

public class EfRepository<T> : IRepository<T> where T : class
{
    private readonly AppDbContext _db;
    public EfRepository(AppDbContext db) => _db = db;

    public Task<T?> GetByIdAsync(int id, CancellationToken ct) =>
        _db.Set<T>().FindAsync([id], ct).AsTask();

    public async Task<IEnumerable<T>> GetAllAsync(CancellationToken ct) =>
        await _db.Set<T>().ToListAsync(ct);

    public async Task AddAsync(T entity, CancellationToken ct) =>
        await _db.Set<T>().AddAsync(entity, ct);

    public Task SaveChangesAsync(CancellationToken ct) => _db.SaveChangesAsync(ct);
}

This abstraction hides nothing useful. It just adds ceremony, blocks LINQ composition, and forces you to add a new method every time you need a filter.

โœ… When a Repository Actually Pays Rent

Microsoft's DDD/microservices guide recommends repositories only when they bring real value:

  • One repository per aggregate root (not per table).
  • They encapsulate domain operations, not generic CRUD.
  • They make unit tests independent from the database.
  • They help apply cross-cutting concerns (caching, logging) via decorators.

Here is the version I actually ship:

public interface IOrderQueries
{
    Task<OrderSummary?> GetSummaryAsync(int orderId, CancellationToken ct);
    Task<IReadOnlyList<OrderSummary>> GetRecentForCustomerAsync(
        int customerId, int take, CancellationToken ct);
}

public sealed class OrderQueries : IOrderQueries
{
    private readonly AppDbContext _db;
    public OrderQueries(AppDbContext db) => _db = db;

    public Task<OrderSummary?> GetSummaryAsync(int orderId, CancellationToken ct) =>
        _db.Orders
            .Where(o => o.Id == orderId)
            .Select(o => new OrderSummary(o.Id, o.CustomerName, o.Total, o.Status))
            .AsNoTracking()
            .FirstOrDefaultAsync(ct);

    public async Task<IReadOnlyList<OrderSummary>> GetRecentForCustomerAsync(
        int customerId, int take, CancellationToken ct) =>
        await _db.Orders
            .Where(o => o.CustomerId == customerId)
            .OrderByDescending(o => o.CreatedAt)
            .Take(take)
            .Select(o => new OrderSummary(o.Id, o.CustomerName, o.Total, o.Status))
            .AsNoTracking()
            .ToListAsync(ct);
}

public record OrderSummary(int Id, string CustomerName, decimal Total, string Status);

Notice what this layer does:

  • Returns IReadOnlyList, not IQueryable โ€” the call site can't keep composing the query.
  • Projects to DTOs, not entities.
  • Expresses domain intent, not generic CRUD.

That's a repository (or query service) that pays rent.

๐Ÿชถ The Dapper Case: A Different Story

With Dapper, the conversation flips.

Dapper is just a thin extension over IDbConnection. There is no DbContext, no change tracker, no built-in Unit of Work. Raw SQL is everywhere.

If you call connection.QueryAsync<Order>("SELECT ...") directly from your services, you are leaking SQL strings, connection management, and parameter handling into your business logic.

This is exactly where the Repository pattern shines.

public interface IOrderReadRepository
{
    Task<OrderSummary?> GetSummaryAsync(int orderId, CancellationToken ct);
}

public sealed class DapperOrderReadRepository : IOrderReadRepository
{
    private readonly IDbConnectionFactory _connectionFactory;

    public DapperOrderReadRepository(IDbConnectionFactory connectionFactory) =>
        _connectionFactory = connectionFactory;

    public async Task<OrderSummary?> GetSummaryAsync(int orderId, CancellationToken ct)
    {
        using var connection = await _connectionFactory.CreateAsync(ct);

        const string sql = """
            SELECT o.Id, o.CustomerName, o.Total, o.Status
            FROM Orders o
            WHERE o.Id = @OrderId;
            """;

        return await connection.QuerySingleOrDefaultAsync<OrderSummary>(
            new CommandDefinition(sql, new { OrderId = orderId }, cancellationToken: ct));
    }
}

Microsoft's own guidance in the DDD/CQRS architecture book actually pairs the two:

  • EF Core repositories for the command side (writes, transactions, aggregates).
  • Dapper repositories for the query side (fast, flat reads, projections).

๐Ÿ“œ What Microsoft Learn Actually Recommends

If I had to summarize the official position in one sentence:

"Use the Repository pattern when it adds value โ€” not by default."

The key takeaways from Microsoft Learn:

  1. EF Core already gives you Repository + Unit of Work through DbContext. (source)
  2. Repositories are not mandatory in DDD or general .NET. (source)
  3. One repository per aggregate root, not per table.
  4. Don't return IQueryable from repositories โ€” return materialized results (IReadOnlyList, IAsyncEnumerable). Otherwise, the abstraction leaks.
  5. Use repositories for testing โ€” mocking DbContext is harder than mocking an interface.
  6. CQRS friendly: EF Core for writes, Dapper for reads.

๐ŸŽฏ My Personal Rules of Thumb

After enough projects, here's the checklist I run through:

  • ๐ŸŸข EF Core only, small/medium CRUD app? Use DbContext directly. Don't wrap it.
  • ๐ŸŸข EF Core + DDD with aggregates? One repository per aggregate root, focused methods, no generic CRUD.
  • ๐ŸŸข Dapper or raw ADO.NET? Yes, wrap it. SQL doesn't belong in your services.
  • ๐ŸŸข Heavy reads / reporting? Build query services (IOrderQueries) that return DTOs.
  • ๐Ÿ”ด Generic IRepository<T> over EF Core? Don't. You are duplicating DbSet<T>.

๐Ÿง  The Real Question to Ask

Whenever I'm tempted to add a repository, I ask myself one question:

"Does this abstraction pay rent?"

If it simplifies my domain, protects my call sites, makes tests easier, or hides a messy data access technology โ€” yes, it pays rent.

If it just renames DbContext methods and adds a layer of indirection โ€” it doesn't.

The pattern name is not the point. Clarity is.


๐Ÿ“š Further Reading

Takeaway: Add a repository on top of EF Core only when it simplifies your design more than it duplicates it. With Dapper, almost always wrap it. With EF Core, almost never wrap it generically.