DEV Community

Cover image for EF Core in Clean Architecture — Pragmatic Approach: Avoiding Repository Overkill
Vikrant Bagal
Vikrant Bagal

Posted on

EF Core in Clean Architecture — Pragmatic Approach: Avoiding Repository Overkill

Stop wrapping EF Core in repositories — you're adding an abstraction over an abstraction and slowing your team down.

EF Core in Clean Architecture

If you've built a .NET Clean Architecture project recently, you've probably created a Repository<T> interface, injected it into a handler, and felt a twinge of guilt writing the same exact method signature you just defined on DbContext.Set<T>(). You're not alone. The pragmatic shift happening in the .NET community right now is simple: EF Core already is a repository and unit of work. Let me show you why skipping the extra layer leads to cleaner, more testable, and dramatically leaner Clean Architecture.

EF Core Is Already a Repository — Let's Not Pretend Otherwise

Every time you write context.Orders.Where(o => o.CustomerId == id).ToListAsync(), you're using EF Core's repository pattern. DbSet<T> is the generic repository implementation that Jimmy Bogard and others championed for years. The same DbContext exposes SaveChangesAsync() which is your unit of work commit boundary.

This isn't an opinion — it's the documented architecture of EF Core. Wrapping DbSet<T> behind IRepository<T>, adding GetByIdAsync, AddAsync, and DeleteAsync one-liners, and then injecting that into handlers gives you exactly zero additional behavior. It's ceremony, not abstraction.

Here's what that abstraction over an abstraction looks like — and what we cut out:

// ❌ The boilerplate we're leaving behind
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(int id, CancellationToken ct = default);
    Task<IEnumerable<Order>> GetAllAsync(CancellationToken ct = default);
    Task AddAsync(Order order, CancellationToken ct = default);
    Task DeleteAsync(int id, CancellationToken ct = default);
}

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _ctx;
    public OrderRepository(AppDbContext ctx) => _ctx = ctx;

    public Task<Order?> GetByIdAsync(int id, CancellationToken ct = default)
        => _ctx.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);

    public Task<List<Order>> GetAllAsync(CancellationToken ct = default)
        => _ctx.Orders.ToListAsync(ct);

    public async Task AddAsync(Order order, CancellationToken ct = default)
        => await _ctx.Orders.AddAsync(order, ct);

    public async Task DeleteAsync(int id, CancellationToken ct = default)
    {
        var order = await _ctx.Orders.FindAsync(new object[] { id }, ct);
        if (order is not null) _ctx.Orders.Remove(order);
    }
    // ... 40 more methods for every entity
}
Enter fullscreen mode Exit fullscreen mode

Instead, your Application layer uses IApplicationDbContext directly — which we define to keep boundaries intact.

Pragmatic Layering: Where DbContext Lives and Why

A strict Clean Architecture purist insists Infrastrucutre references Application. Pragmatic Clean Architecture flips this: the Application layer defines an interface the infrastructure implements. This keeps your domain pure (no EF Core dependencies) while allowing your use cases to write real queries.

Here's the contract — owned by the Application layer:

// Application layer — defines the contract
public interface IApplicationDbContext
{
    DbSet<Order> Orders { get; }
    DbSet<Customer> Customers { get; }
    Task<int> SaveChangesAsync(CancellationToken ct = default);
}
Enter fullscreen mode Exit fullscreen mode

And the Infrastructure layer implements it — this is where EF Core lives:

// Infrastructure layer — owns EF Core configuration
public class AppDbContext : DbContext, IApplicationDbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}
Enter fullscreen mode Exit fullscreen mode

Your handler now receives IApplicationDbContext and writes composable, full-power LINQ queries with no repository gatekeeping:

// Application use case — straightforward, no indirection
public record GetCustomerOrdersQuery(int CustomerId, int Page, int PageSize)
    : IRequest<PagedList<OrderDto>>;

internal sealed class GetCustomerOrdersHandler
    : IRequestHandler<GetCustomerOrdersQuery, PagedList<OrderDto>>
{
    private readonly IApplicationDbContext _db;

    public GetCustomerOrdersHandler(IApplicationDbContext db)
        => _db = db;

    public async Task<PagedList<OrderDto>> Handle(
        GetCustomerOrdersQuery query, CancellationToken ct)
    {
        var orders = _db.Orders
                    .Where(o => o.CustomerId == query.CustomerId)
                    .OrderByDescending(o => o.CreatedAt);

        return await PagedList<OrderDto>.CreateAsync(
            orders.ProjectToDto(), query.Page, query.PageSize, ct);
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice you didn't need Include boilerplate, didn't construct DTO projection manually — you composed fully filtered, tracked, paged, projected query.

Reusing Queries Without Repository Method Explosion

The common fear: without repositories, query logic will be copied across handlers. But two patterns handle reuse without exploding the repository API.

IQueryable extensions — keep composability while deduplicating:

// Application layer — query specifications
public static class OrderQueryExtensions
{
    public static IQueryable<Order> CustomerOrders(
        this IApplicationDbContext ctx, int customerId)
        => ctx.Orders.Where(o => o.CustomerId == customerId);

    public static IQueryable<OrderDto> ProjectToDto(this IQueryable<Order> q)
        => q.Select(o => new OrderDto
        {
            Id = o.Id,
            OrderDate = o.CreatedAt,
            Total = o.LineItems.Sum(li => li.Quantity * li.UnitPrice)
        });
}

// Usage — fully composable, still reusable
var dtos = await _db.CustomerOrders(query.CustomerId)
                   .ProjectToDto()
                   .OrderByDescending(d => d.OrderDate)
                   .ToListAsync(ct);
Enter fullscreen mode Exit fullscreen mode

Use the Specification pattern when you need to encapsulate business rules — but keep its return IQueryable<T>, not IEnumerable, because tracking and composition still work:

public sealed class HighValueOrderSpec : Specification<Order>
{
    private readonly decimal _threshold;
    public HighValueOrderSpec(decimal threshold) => _threshold = threshold;
    public override Expression<Func<Order, bool>> ToExpression()
        => o => o.LineItems.Sum(li => li.Quantity * li.UnitPrice) > _threshold;
}

// Handler uses it — still composable
var highValue = _db.Orders.Where(new HighValueOrderSpec(500m).ToExpression());
Enter fullscreen mode Exit fullscreen mode

This approach preserves every LINQ provider benefit — AsNoTracking, Select, client vs server evaluation — and you never write a GetBySpecificationAsync method that the repository would eventually need anyway.

Testing: Mocked Repositories vs. Real Database With Testcontainers

The biggest hidden cost of repository abstractions is testing. When you mock IRepository<T>, you're asserting against a fake. Your mock says FindByIdAsync always returns Task.FromResult(null) — but EF Core real query miss throws? Wrong translation? Missing Include?

You never know until production.

Pragmatic approach: write integration tests against a real database with Testcontainers. EF In-Memory works for pure shape validation, but TestContainers with actual SQL Server, PostgreSQL or whatever you ship is where correctness lives.

[Fact]
public async Task CustomerOrders_ReturnsOnlyMatchingCustomer()
{
    await using var db = new ApplicationDbContextFactory(
        new DbContextOptionsBuilder<ApplicationDbContext>()
            .UseSqlServer(_container.GetConnectionString())
            .Options);

    // Seed
    db.Customers.AddRange(
        new Customer(1, "Alice"),
        new Customer(2, "Bob"));
    db.Orders.AddRange(
        new Order { CustomerId = 1, CreatedAt = DateTime.UtcNow.AddDays(-1) },
        new Order { CustomerId = 1, CreatedAt = DateTime.UtcNow },
        new Order { CustomerId = 2, CreatedAt = DateTime.UtcNow });
    await db.SaveChangesAsync(CancellationToken.None);

    // Act
    var result = await db.Orders
        .Where(o => o.CustomerId == 1)
        .ToListAsync(CancellationToken.None);

    // Assert
    Assert.Equal(2, result.Count);
    Assert.All(result, o => Assert.Equal(1, o.CustomerId));
}
Enter fullscreen mode Exit fullscreen mode

Why this beats mocked repositories:

  • Catches Include/ThenInclude relationship misses
  • Catches client-evaluation issues (the #1 cause of slow EF code)
  • Catches LINQ translation failures (especially after upgrades)
  • Catches tracking/no-tracking behavior surprised
  • Tests real concurrency and transaction semantics

Use repos when you need true cross-cutting concerns — distributed caching, soft delete wiring, tenant isolation, audit log enrichment — things that shape every query. Add one decorators per concern. That's still not IGenericRepository<T> explosion.

Measurable Results After Ditching Generic Repos

After removing IRepository and IUoW from several mid-scale services, teams consistently report:

  • Fewer project files — no per-entity repository interfaces and implementations
  • Faster feature delivery — just write the query in the hander, stop abstractions trees passing
  • Better correctness — real database tests catch translation, Include, concurrency, projection-to-null, aggregations that mocked repositories never touched
  • No pain during provider switches — none. EF Core provider model handles Db2, Oracle, SQLite, MySQL, Npgsql

When to Use a Repository (Sparely)

Layer IRepository<T> only for cross-cutting vertical concerns:

  • Caching decorators
  • Decorators

Not per-entity CRUD wrappers — that's DbSet<T>'s job already.

Conclusion: Start Clean Architecture without the generic repository layer. Use EF Core's DbSet<T> and SaveChangesAsync() directly from Application. If you ever need to swap data access technology entirely, you'll rewrite use cases anyway because ShapeQuery() doesn't translate to anything else. Take the wins now — no repositories, real DbContext dependency, integration tests with TestContainers, and queries that fully leverage LINQ. Less code, higher confidence, same boundaries.

Top comments (0)