Stop Wrapping EF Core in Repositories: Use Specifications + Clean Architecture

GitHub project which accompanies this article is available here  

Wrapping Entity Framework Core in repositories has become a default in many .NET codebases.

But defaults deserve to be challenged.

This post shows:

  • why repository-over-EF breaks down

  • how Clean Architecture + Specification Pattern fixes it

  • and how EF Core InMemory tests prove the approach works


The problem with “Repository Pattern over EF Core”

EF Core already gives you:

  • DbSet<T> → repository behavior

  • DbContext → unit of work

Yet many projects add another repository layer anyway.

It usually starts simple:

Add(order) GetById(id) Update(order)

Then order-processing requirements arrive:

  • “Get open orders for customer”

  • “Get orders awaiting payment older than 7 days”

  • “Get paged orders sorted by date with items”

Soon you’re staring at this:

OrderRepository ├── GetOpenOrdersForCustomer(...) ├── GetOrdersAwaitingPayment(...) ├── GetOrdersWithItemsAndPayments(...) ├── GetPagedOrdersByStatus(...)

At this point:

  • repositories contain business rules

  • query intent is hidden in method names

  • EF concepts leak upward

  • testing becomes brittle


What we actually want

For an order-processing system, we want:

  • query intent expressed clearly

  • reusable, composable queries

  • EF Core isolated in Infrastructure

  • application logic that reads like business language


The replacement: Clean Architecture + Specification Pattern

Clean Architecture

  • Domain: Orders, items, statuses

  • Application: Use-cases + query intent

  • Infrastructure: EF Core, persistence, query execution

EF Core becomes an implementation detail.

Specification Pattern

Instead of repository methods, we define query objects:

  • OrderWithDetailsByIdSpec

  • OpenOrdersForCustomerSpec

  • OrdersAwaitingPaymentSpec

Each specification defines:

  • filtering

  • includes

  • sorting

  • paging

Each one is named after business intent.


Architecture layout

Domain └── Orders Application ├── Specifications ├── Queries └── Repository Abstractions Infrastructure ├── EF Core DbContext ├── SpecificationEvaluator └── Repository Implementations

Example: Order specifications

Load an order with details

public sealed class OrderWithDetailsByIdSpec : Specification<Order> { public OrderWithDetailsByIdSpec(Guid orderId) { Criteria = o => o.Id == orderId; AddInclude(o => o.Items); } }

Open orders for a customer (paged, newest first)

public sealed class OpenOrdersForCustomerSpec : Specification<Order> { public OpenOrdersForCustomerSpec(Guid customerId, int page, int size) { Criteria = o => o.CustomerId == customerId && o.Status != OrderStatus.Completed && o.Status != OrderStatus.Cancelled; ApplyOrderByDescending(o => o.CreatedUtc); ApplyPaging((page - 1) * size, size); } }

Orders awaiting payment

public sealed class OrdersAwaitingPaymentSpec : Specification<Order> { public OrdersAwaitingPaymentSpec(int olderThanDays, DateTime nowUtc) { var cutoff = nowUtc.AddDays(-olderThanDays); Criteria = o => o.Status == OrderStatus.AwaitingPayment && o.CreatedUtc <= cutoff; ApplyOrderBy(o => o.CreatedUtc); } }

Use-cases stay clean

public async Task<Order?> Handle(GetOrderDetailsQuery query) { var spec = new OrderWithDetailsByIdSpec(query.OrderId); return await _orders.FirstOrDefaultAsync(spec); }

No EF Core.
No includes.
No query logic.

Just intent.


But does this actually work?

Patterns are cheap without proof.

So let’s prove it using EF Core InMemory + xUnit, without mocks.


Testing setup (EF Core InMemory)

We use:

  • EF Core InMemory provider

  • real DbContext

  • real repositories

  • real specifications

Each test gets its own isolated in-memory database.

public static class InMemoryDbFactory { public static AppDbContext Create() { var options = new DbContextOptionsBuilder<AppDbContext>() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new AppDbContext(options); } }

Test 1: Specification filters + paging + sorting

This test proves:

  • only open orders are returned

  • paging is applied

  • newest-first ordering works

[Fact] public async Task OpenOrdersForCustomerSpec_FiltersAndPages() { using var db = InMemoryDbFactory.Create(); var customerId = Guid.NewGuid(); var now = DateTime.UtcNow; db.Orders.Add(OrderTestData.CreateOrder(Guid.NewGuid(), customerId, now, OrderStatus.Submitted)); db.Orders.Add(OrderTestData.CreateOrder(Guid.NewGuid(), customerId, now.AddDays(-1), OrderStatus.Completed)); db.Orders.Add(OrderTestData.CreateOrder(Guid.NewGuid(), customerId, now.AddDays(-2), OrderStatus.Submitted)); await db.SaveChangesAsync(); var repo = new EfReadRepository<Order>(db); var spec = new OpenOrdersForCustomerSpec(customerId, 1, 10); var results = await repo.ListAsync(spec); Assert.Equal(2, results.Count); Assert.True(results[0].CreatedUtc >= results[1].CreatedUtc); }

Test 2: Orders awaiting payment cutoff logic

This test proves:

  • date-based criteria works

  • status filtering works

  • oldest-first sorting works

[Fact] public async Task OrdersAwaitingPaymentSpec_ReturnsOnlyOldOrders() { using var db = InMemoryDbFactory.Create(); var now = new DateTime(2026, 1, 1); db.Orders.Add(OrderTestData.CreateOrder(Guid.NewGuid(), Guid.NewGuid(), now.AddDays(-10), OrderStatus.AwaitingPayment)); db.Orders.Add(OrderTestData.CreateOrder(Guid.NewGuid(), Guid.NewGuid(), now.AddDays(-5), OrderStatus.AwaitingPayment)); await db.SaveChangesAsync(); var repo = new EfReadRepository<Order>(db); var spec = new OrdersAwaitingPaymentSpec(7, now); var results = await repo.ListAsync(spec); Assert.Single(results); Assert.True(results[0].CreatedUtc <= now.AddDays(-7)); }

Test 3: Same repository, different intent

This test is the core proof.

The repository never changes.
Only the specification does.

[Fact] public async Task SameRepositoryExecutesDifferentSpecs() { using var db = InMemoryDbFactory.Create(); var now = DateTime.UtcNow; var customerId = Guid.NewGuid(); db.Orders.Add(OrderTestData.CreateOrder(Guid.NewGuid(), customerId, now.AddDays(-10), OrderStatus.AwaitingPayment)); db.Orders.Add(OrderTestData.CreateOrder(Guid.NewGuid(), customerId, now, OrderStatus.Submitted)); await db.SaveChangesAsync(); var repo = new EfReadRepository<Order>(db); var openOrders = await repo.ListAsync( new OpenOrdersForCustomerSpec(customerId, 1, 10)); var overdueOrders = await repo.ListAsync( new OrdersAwaitingPaymentSpec(7, now)); Assert.Equal(2, openOrders.Count); Assert.Single(overdueOrders); }

What these tests prove

  • Specifications encode query intent

  • Repositories remain generic and stable

  • EF Core stays in Infrastructure

  • No mocks required

  • No query methods added over time


Important note on EF Core InMemory

EF Core InMemory is perfect for:

  • pattern validation

  • fast tests

  • documentation and examples

For production-grade query behavior (joins, includes, relational constraints), SQLite in-memory is closer — but for this architectural proof, InMemory is sufficient and readable.


Final takeaway

If your OrderRepository is growing faster than your features:

  • You don’t have a data problem

  • You have an intent expression problem

Clean Architecture + Specification Pattern fixes that — and the tests prove it.

GitHub project which accompanies this article is available here

Comments

Popular posts from this blog

Using a Semantic Model as a Reasoning Layer (Not Just Metadata)

A Thought Experiment: What If Analytics Models Were Semantic, Not Structural?

Dev Tunnels with Visual Studio 2022 and Visual Studio 2026