The Repository Pattern: When to Use It and When to Skip It
Few patterns generate as much debate in the .NET community as the repository pattern. Some developers consider it essential; others call it a pointless abstraction over Entity Framework's DbContext, which is already a repository and unit of work. Both sides have valid points. Let's look at this honestly.
What the Repository Pattern Does
A repository provides a collection-like interface for accessing domain objects, hiding the details of data access behind an abstraction.
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<Order>> GetByCustomerAsync(Guid customerId, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
Task RemoveAsync(Order order, CancellationToken ct = default);
}
The implementation wraps EF Core:
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public OrderRepository(AppDbContext db) => _db = db;
public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct) =>
await _db.Orders
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task<IReadOnlyList<Order>> GetByCustomerAsync(
Guid customerId, CancellationToken ct) =>
await _db.Orders
.Where(o => o.CustomerId == customerId)
.ToListAsync(ct);
public async Task AddAsync(Order order, CancellationToken ct) =>
await _db.Orders.AddAsync(order, ct);
public async Task RemoveAsync(Order order, CancellationToken ct) =>
await Task.Run(() => _db.Orders.Remove(order), ct);
}
When Repositories Help
Encapsulating complex queries. If loading an aggregate requires specific includes, filters, or ordering, a repository method gives that query a name and a single home. GetActiveOrdersWithLines() is clearer than repeating the same LINQ chain in five handlers.
Enforcing aggregate boundaries. In DDD, you load and save aggregates as a whole. A repository ensures consumers always load the complete aggregate, not a partial entity graph.
Abstracting persistence for testability. When your domain layer must not reference EF Core, repository interfaces defined in the domain provide a clean seam.
// In tests — no database needed
var fakeRepo = new FakeOrderRepository();
fakeRepo.Add(new Order("[email protected]"));
var handler = new GetOrderHandler(fakeRepo);
var result = await handler.Handle(new GetOrderQuery(orderId), CancellationToken.None);
When Repositories Hurt
Generic repositories. The infamous IRepository<T> with GetAll(), Find(), Add(), and Remove() is almost always a mistake. It exposes IQueryable<T>, which leaks EF Core's query capabilities through the abstraction. Consumers end up writing LINQ against the repository, defeating the entire purpose.
// This is not abstraction — it's wrapping
public interface IRepository<T> where T : class
{
IQueryable<T> GetAll(); // leaks EF Core
Task<T?> GetByIdAsync(int id);
void Add(T entity);
void Remove(T entity);
}
Simple CRUD applications. If your application is primarily data in, data out with minimal business logic, DbContext already provides everything you need. Adding a repository layer doubles your surface area without adding value.
One repository per table. Repositories should align with aggregate roots, not database tables. An OrderRepository that also has an OrderLineRepository misses the point — order lines belong to the order aggregate.
The Alternative: Use DbContext Directly
In vertical slice architecture, handlers often use DbContext directly:
public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDto?>
{
private readonly AppDbContext _db;
public GetOrderHandler(AppDbContext db) => _db = db;
public async Task<OrderDto?> Handle(GetOrderQuery request, CancellationToken ct)
{
return await _db.Orders
.Where(o => o.Id == request.OrderId)
.Select(o => new OrderDto(o.Id, o.CustomerEmail, o.Total))
.FirstOrDefaultAsync(ct);
}
}
For testing, use EF Core's in-memory provider or, better yet, Testcontainers to spin up a real database. This tests your actual queries rather than a fake that behaves differently from the real thing.
A Balanced Approach
You don't have to choose all or nothing. A pragmatic approach:
- Use repositories for aggregates with complex loading logic — where encapsulation genuinely adds value
- Use DbContext directly for simple queries and read models — especially projections into DTOs
- Never use generic repositories — write specific interfaces with meaningful method names
- Align repositories with aggregate roots — not with tables
The repository pattern is a tool, not a rule. Use it where it reduces complexity, skip it where it adds ceremony without benefit.