Clean Architecture is one of the most popular architectural styles in .NET today.
Clean Architecture aims to separate the application's concerns into distinct layers, promoting high cohesion and low coupling.
In the classic implementation you need to hide EF Core behind a Repository so the Application layer never depends on the ORM.
I followed that advice for years.
But after many production projects, I changed my mind and adopted a more pragmatic approach to implementing Clean Architecture.
In this post, I want to explain why I no longer wrap EF Core in a repository inside Clean Architecture, and why I think most teams should do the same.
We will look at what the original Clean Architecture really says and how a pragmatic approach keeps the same boundaries in the code.
In this post, we will explore:
- The Traditional Clean Architecture with a Repository
- The Hidden Cost of Repositories in Clean Architecture
- Pragmatic Clean Architecture: Use EF Core Directly in Application Use Cases
- What if We Switch a Database Later?
- When You Might Still Need a Custom Repository
Let's dive in.
👉 Read original article on my newsletter: https://antondevtips.com/blog/ef-core-in-clean-architecture-the-pragmatic-way
The Traditional Clean Architecture with a Repository
Clean Architecture splits an application into four layers, each with a clear role:
- Domain — entities, value objects, and core business rules.
- Application — use cases, commands, queries, and handlers.
- Infrastructure — implementations of external concerns: database, cache, message bus, email, identity provider.
- Presentation — the entry point: Web API, gRPC, GraphQL, MVC, Blazor, etc.
The key rule is the Dependency Rule: dependencies point inwards:
- Domain knows nothing about other layers
- Application depends only on the Domain.
- Infrastructure and Presentation depend on Application and Domain.
In the traditional setup, the Application layer defines the Repository contract:
public interface IShipmentRepository
{
Task<Shipment?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<bool> ExistsForOrderAsync(string orderId, CancellationToken cancellationToken);
Task AddAsync(Shipment shipment, CancellationToken cancellationToken);
}
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
Infrastructure implements it on top of EF Core:
internal sealed class ShipmentRepository(ShipmentsDbContext context) : IShipmentRepository
{
public Task<Shipment?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
=> context.Shipments
.Include(x => x.Items)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
public Task<bool> ExistsForOrderAsync(string orderId, CancellationToken cancellationToken)
=> context.Shipments.AnyAsync(x => x.OrderId == orderId, cancellationToken);
public async Task AddAsync(Shipment shipment, CancellationToken cancellationToken)
=> await context.Shipments.AddAsync(shipment, cancellationToken);
}
internal sealed class UnitOfWork(ShipmentsDbContext context) : IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken cancellationToken)
=> context.SaveChangesAsync(cancellationToken);
}
A use case handler injects the abstractions:
internal sealed class CreateShipmentHandler(
IShipmentRepository repository,
IUnitOfWork unitOfWork,
ILogger<CreateShipmentHandler> logger)
{
public async Task<Result<ShipmentResponse>> HandleAsync(
CreateShipmentCommand command,
CancellationToken cancellationToken)
{
var exists = await repository.ExistsForOrderAsync(command.OrderId, cancellationToken);
if (exists)
{
return ShipmentErrors.AlreadyExists(command.OrderId);
}
var shipment = command.MapToShipment();
await repository.AddAsync(shipment, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);
return shipment.MapToResponse();
}
}
The Dependency Rule is satisfied, and you can switch the implementation in tests. So far, so good.
But...
The Hidden Cost of Repositories in Clean Architecture
I have built many systems with this pattern. Here is what tends to happen as the project grows.
1. A repository is an abstraction over an abstraction.
EF Core's DbContext already implements both the Repository pattern and the Unit of Work pattern.
This is stated in the official DbContext summary. When you put IShipmentRepository on top of it, you are wrapping a wrapper.
When we create a repository over EF Core, we create an abstraction over an abstraction, leading to over-engineered solutions.
Each DbSet<TEntity> in your DbContext represents a collection of entities, just like a typical repository.
It allows you to:
- Query data using LINQ
- Add, update, and remove entities
- Project data to other types
2. Method explosion.
What starts as four methods becomes 20:
public interface IShipmentRepository
{
Task<Shipment?> GetByIdAsync(...);
Task<List<Shipment>> GetByOrderIdAsync(...);
Task<List<Shipment>> GetByCarrierAsync(...);
Task<List<Shipment>> GetActiveAsync(...);
Task<List<Shipment>> GetByStatusAsync(...);
Task<List<Shipment>> GetCreatedBetweenAsync(...);
Task<List<Shipment>> GetWithItemsAsync(...);
// ... many more
}
Every new feature adds a new method.
After a year, no one knows whether the method they need already exists, so they add another one that does almost the same thing.
3. Cross-entity queries have no right place.
What if a feature needs a shipment, its order, plus the customer's address?
Where does that method live? IShipmentRepository? IOrderRepository? A new IShipmentOrderRepository?
4. Includes and projections leak or duplicate.
Some callers need Include(x => x.Items), some do not.
Some want a ShipmentResponse projection, and some want the full entity.
You end up with GetByIdWithItemsAsync, GetByIdSlimAsync, GetByIdForReportAsync — or you load too much data on every call.
5. Mocked-repository tests give false confidence.
Mocking IShipmentRepository is fast, but it does not test how EF Core translates your LINQ-to-SQL queries, whether your Include clauses work, or whether your database uniqueness constraints are enforced.
The exact things that break in production.
6. Slower feature delivery.
Every new use case touches three files: the repository interface, the repository implementation, and the handler.
For a single new query, that is a lot of ceremony.
Now, let's look at a pragmatic approach to using EF Core in Clean Architecture.
👉 Read original article on my newsletter: https://antondevtips.com/blog/ef-core-in-clean-architecture-the-pragmatic-way
Top comments (0)