Repository Pattern in .NET: When It Pays Rent (and When It Doesn't)

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.
DbContextalready 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.SaveChangesis 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, notIQueryableโ 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:
- EF Core already gives you Repository + Unit of Work through
DbContext. (source) - Repositories are not mandatory in DDD or general .NET. (source)
- One repository per aggregate root, not per table.
- Don't return
IQueryablefrom repositories โ return materialized results (IReadOnlyList,IAsyncEnumerable). Otherwise, the abstraction leaks. - Use repositories for testing โ mocking
DbContextis harder than mocking an interface. - 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
DbContextdirectly. 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 duplicatingDbSet<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
- EF Core: Testing without your production database (Repository pattern)
- Design the infrastructure persistence layer
- Implement the infrastructure persistence layer with EF Core
- ASP.NET Core + EF Core: Advanced scenarios
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.
