The Repository Pattern Revisited cover image

The Repository Pattern Revisited

By Julian Krause · Saturday, February 28, 2026 · ~3 min read

The Repository pattern is one of the most debated patterns in the .NET ecosystem. Some teams swear by it, others consider it an unnecessary abstraction over Entity Framework Core. After implementing repositories in dozens of projects — and removing them from several — here is where I have landed.

The Case Against Generic Repositories

The generic repository (IRepository<T> with CRUD methods) is the most common implementation and, in my experience, the least useful. It abstracts away the features that make EF Core powerful while adding a layer that provides minimal value.

// The generic repository anti-pattern
public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id);
    Task<IEnumerable<T>> GetAllAsync();
    Task AddAsync(T entity);
    void Update(T entity);
    void Delete(T entity);
    Task SaveChangesAsync();
}

// What you lose: navigation properties, projections, includes,
// compiled queries, bulk operations, raw SQL, change tracking control.
// What you gain: an interface you can mock. That's it.

The DbContext is already a Unit of Work. The DbSet is already a Repository. Wrapping them in another layer of abstraction is ceremony without substance.

When Repositories Earn Their Keep

Repositories become valuable when they encapsulate complex query logic specific to an aggregate. Not generic CRUD — specific, domain-meaningful operations.

// A repository that earns its place
public interface IOrderRepository
{
    Task<Order?> GetWithLinesAsync(Guid publicId, CancellationToken ct);
    Task<IReadOnlyList<Order>> GetPendingOrdersOlderThanAsync(TimeSpan age, CancellationToken ct);
    Task<int> GetMonthlyOrderCountForCustomerAsync(Guid customerId, CancellationToken ct);
    void Add(Order order);
}

public class OrderRepository(AppDbContext db) : IOrderRepository
{
    public async Task<Order?> GetWithLinesAsync(Guid publicId, CancellationToken ct)
    {
        return await db.Orders
            .Include(o => o.Lines)
                .ThenInclude(l => l.Product)
            .FirstOrDefaultAsync(o => o.PublicId == publicId, ct);
    }

    public async Task<IReadOnlyList<Order>> GetPendingOrdersOlderThanAsync(
        TimeSpan age, CancellationToken ct)
    {
        var cutoff = DateTimeOffset.UtcNow - age;
        return await db.Orders
            .Where(o => o.Status == OrderStatus.Pending && o.CreatedAt < cutoff)
            .OrderBy(o => o.CreatedAt)
            .ToListAsync(ct);
    }

    public async Task<int> GetMonthlyOrderCountForCustomerAsync(
        Guid customerId, CancellationToken ct)
    {
        var startOfMonth = new DateTimeOffset(
            DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, 1,
            0, 0, 0, TimeSpan.Zero);

        return await db.Orders
            .CountAsync(o => o.Customer.PublicId == customerId
                          && o.CreatedAt >= startOfMonth, ct);
    }

    public void Add(Order order) => db.Orders.Add(order);
}

The Direct DbContext Approach

For many applications, especially those using vertical slice architecture, injecting the DbContext directly into handlers is the simplest and most effective approach. Each handler writes its own query, optimized for its specific use case.

public class GetOrderHandler(AppDbContext db) : IRequestHandler<GetOrderQuery, OrderDto?>
{
    public async Task<OrderDto?> Handle(GetOrderQuery request, CancellationToken ct)
    {
        return await db.Orders
            .AsNoTracking()
            .Where(o => o.PublicId == request.OrderId)
            .Select(o => new OrderDto
            {
                Id = o.PublicId,
                Status = o.Status.ToString(),
                Total = o.Lines.Sum(l => l.Quantity * l.UnitPrice),
                CustomerName = o.Customer.Name
            })
            .FirstOrDefaultAsync(ct);
    }
}

The Pragmatic Middle Ground

My current recommendation: use the DbContext directly for queries (reads) and aggregate-specific repositories for commands (writes). Queries are inherently use-case-specific and benefit from direct access to EF Core's projection and optimization features. Commands operate on aggregates and benefit from a repository that encapsulates the loading and persistence of the aggregate with its invariants.

The key principle is: add abstraction only when it provides measurable value. If your repository is a pass-through to the DbContext, delete it. If it encapsulates meaningful domain query logic that would otherwise be duplicated, keep it.


Comments (2)

David Monday, March 2, 2026 5:08 PM

Could you elaborate on this topic in a follow-up post?

Felix Thursday, February 26, 2026 8:08 PM

Interesting thought, thanks for adding that.

Greta Friday, February 27, 2026 8:08 PM

Thanks for your comment — glad it helped!

Elena Tuesday, March 3, 2026 5:08 PM

This is exactly what I was looking for, thank you!

Leave a Comment