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.
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:
csharppublic 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:
csharpinternal 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:
csharpinternal 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:
csharppublic 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.
Pragmatic Clean Architecture: Use EF Core Directly in Application Use Cases
The classic approach creates a lot of unnecessary code in real projects.
I prefer a more pragmatic approach when implementing architecture in my projects. I allow my Application use cases to talk to EF Core directly.
This is what changes in the pragmatic version of Clean Architecture:
- Domain knows nothing about EF Core. (Same as before.)
- Application is allowed to depend on
Microsoft.EntityFrameworkCoreand on theDbContext. (This is the change.) - Infrastructure still owns the concrete
DbContextconfiguration, migrations, interceptors, and any non-EF concerns.
The Dependency Rule still holds for what matters.
Your business rules: entities, value objects, invariants and state transitions don't depend on any framework. Your use cases call for the right tool to talk to a database: EF Core.
Here is the same CreateShipmentHandler after the rewrite:
csharpinternal sealed class CreateShipmentHandler( ShipmentsDbContext context, ILogger<CreateShipmentHandler> logger) { public async Task<Result<ShipmentResponse>> HandleAsync( CreateShipmentCommand command, CancellationToken cancellationToken) { var exists = await context.Shipments .AnyAsync(x => x.OrderId == command.OrderId, cancellationToken); if (exists) { logger.LogInformation("Shipment for order '{OrderId}' already exists", command.OrderId); return ShipmentErrors.AlreadyExists(command.OrderId); } var shipment = command.MapToShipment(); await context.Shipments.AddAsync(shipment, cancellationToken); await context.SaveChangesAsync(cancellationToken); logger.LogInformation("Created shipment: {@Shipment}", shipment); return shipment.MapToResponse(); } }
Overall, the amount of code in the application use case hasn't changed. We just switched from repository to using DbContext directly.
But at the same time, we got rid of the repository and its interface.
We still get the same boundaries. But the handler is easier to read, debug, and change.
A query handler changes in the same way:
csharpinternal sealed class GetShipmentByNumberHandler(ShipmentsDbContext context) { public async Task<Result<ShipmentResponse>> HandleAsync( string shipmentNumber, CancellationToken cancellationToken) { var shipment = await context.Shipments .Include(x => x.Items) .FirstOrDefaultAsync(x => x.Number == shipmentNumber, cancellationToken); if (shipment is null) { return ShipmentErrors.NotFound(shipmentNumber); } return shipment.MapToResponse(); } }
You see the data access right next to the use case logic. This codebase is much easier for humans and even AI to reason about.
This is the layout I now use in every Clean Architecture project.
What if We Switch a Database Later?
When I share this approach, four objections come up almost every time. Let's go through them honestly.
"1. What if we switch database later?"
This is the most common argument for a repository. I take it seriously, but in reality:
- In 99% of projects, the database never changes in production.
- Switching from one relational database to another (SQL Server to PostgreSQL, MySQL to PostgreSQL) keeps almost all of your EF Core code the same. EF Core is the abstraction that already supports multiple databases.
- Switching from a relational database to a document database (MongoDB, Cosmos DB) is more than a repository implementation swap. It is a different data model, different consistency rules and different query patterns. You will rewrite the use cases regardless of how you wrap the ORM.
A repository "in case we switch" almost never pays off. So unless you're actively building a multi-database abstraction layer (which most apps don't need), this reasoning doesn't hold.
"2. Doesn't this break testability?"
It changes how you test, and the change is for the better.
A mocked IShipmentRepository returns whatever you tell it to.
It cannot tell you whether your Include works, whether your Where clause translates to the SQL you expect, or whether your projection avoids loading the whole entity.
Two patterns work well without repositories:
Integration tests with Testcontainers. Run your handlers against a real PostgreSQL or SQL Server in Docker. You test the actual EF Core query, the real schema and the real indexes. These tests catch the bugs that mocks miss.
EF Core InMemory provider. Useful for fast tests of pure handler logic when you do not care about SQL translation. It is not a substitute for integration tests, but it is a fine first layer.
csharp[Fact] public async Task CreateShipment_AlreadyExists_ReturnsConflict() { await using var factory = new ShipmentsApiFactory(); using var scope = factory.Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService<ShipmentsDbContext>(); var handler = scope.ServiceProvider.GetRequiredService<ICreateShipmentHandler>(); context.Shipments.Add(ShipmentFactory.New("ORDER-1")); await context.SaveChangesAsync(); var result = await handler.HandleAsync( new CreateShipmentRequest(OrderId: "ORDER-1", /* ... */), CancellationToken.None); result.IsError.Should().BeTrue(); result.Errors.Should().ContainSingle(e => e.Type == ErrorType.Conflict); }
Tests like this are slower than mocked-repository tests, but they tell you the truth.
"3. What about query reuse?"
Some queries are reused in many places. You do not want to copy a 12-line LINQ chain across ten handlers.
Two lightweight options work well:
1. Extension methods on DbSet<T> or IQueryable<T>.
csharppublic static class ShipmentQueryableExtensions { public static IQueryable<Shipment> WithItems(this IQueryable<Shipment> query) => query.Include(x => x.Items); public static IQueryable<Shipment> ActiveOnly(this IQueryable<Shipment> query) => query.Where(x => x.Status != ShipmentStatus.Cancelled); } // In a handler: var shipment = await context.Shipments .WithItems() .ActiveOnly() .FirstOrDefaultAsync(x => x.Number == number, ct);
2. The Specification Pattern.
For complex, dynamic, combinable queries, the Specification Pattern is a great alternative to a fat repository. Each specification is a small class describing a filter and a sort query. You combine the specifications.
Either approach gives you reuse without coupling every use case to one giant interface.
"3. Doesn't this break the Dependency Rule?"
The Dependency Rule exists to protect your Domain: the part of your code that encodes your business and rarely changes.
EF Core is the persistence library you chose. If you swap it out, you are doing a major rewrite, no matter what.
The cost of pretending otherwise is extra layers, extra files, and slower delivery, every single day, for a switch that almost never happens.
Pragmatic Clean Architecture draws the line where it actually pays off. Your Domain stays pure. Your Application uses EF Core. The boundaries that protect your business rules stay intact.
In other words: keep the spirit of the Dependency Rule, drop the ritual.
When You Might Still Need a Custom Repository
I want to be fair. There are real cases where a repository is the right call:
1. Very Complex Queries That Are Used in Many Places
If you have a query that spans multiple aggregates, involves heavy filtering, sorting, or joins, and is used across many features.
Wrapping it into a repository method can reduce duplication and centralize the logic.
2. Team Conventions or Project Constraints
In some organizations, architectural guidelines strictly require the use of repositories to ensure consistency.
Even if EF Core could be used directly, following the team's agreed-upon conventions might be the pragmatic choice.
3. Cross-Cutting Infrastructure Concerns
Sometimes, you want to decorate repositories with additional behavior, such as caching, logging, or auditing.
While these can also be solved with interceptors or middleware, a repository wrapper might be the simplest approach for your context.
4. External Integrations
If your project queries multiple data sources (e.g., EF Core, an external API, and a legacy database), a repository can act as a facade to unify these sources behind a single abstraction.
5. When Using Dapper
When using Dapper, repositories are essential as they abstract SQL from the rest of your application.
In every other project I have built, dropping the repository made the code smaller, clearer, and faster to evolve.
Summary
Pragmatic Clean Architecture keeps the essentials of Clean Architecture and trims the parts that do not pay off. In my experience, this approach gives me way more benefits than drawbacks.
- Domain stays free of frameworks. Business rules live in entities and value objects.
- Application uses EF Core directly via
DbContext. No customIShipmentRepository, noIUnitOfWork. - Infrastructure owns the
DbContextconfiguration, migrations, and external integrations. - Query reuse comes from
IQueryable<T>extension methods or the Specification Pattern. - Tests run against a real database with Testcontainers, instead of mocked repositories.
The result is less code, faster delivery, more reliable tests, and the same architectural boundaries that made Clean Architecture worth using in the first place.
If you have been writing repositories on top of EF Core out of habit, try one project without them. You will thank me later.
P.S.: As developers are now writing most of the code with AI, it does not matter if AI has to replace the repository implementation or change the code directly in your application use cases (in case you switch a database).
Hope you find this newsletter useful. See you next time.


Comments