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:
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:
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
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
Example: Order specifications
Load an order with details
Open orders for a customer (paged, newest first)
Orders awaiting payment
Use-cases stay clean
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.
Test 1: Specification filters + paging + sorting
This test proves:
-
only open orders are returned
-
paging is applied
-
newest-first ordering works
Test 2: Orders awaiting payment cutoff logic
This test proves:
-
date-based criteria works
-
status filtering works
-
oldest-first sorting works
Test 3: Same repository, different intent
This test is the core proof.
The repository never changes.
Only the specification does.
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
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
Post a Comment