πŸš€ New: The .NET Senior PlaybookSave 20% with launch discountΒ 

newsletter

EF Core in Clean Architecture the Pragmatic Way

7 min read

Newsletter Sponsors

Copied

Your AI Coding Agents Are Running on Someone Else's Cloud (Sponsored)

Autonomous coding agents are writing real code now β€” but most run on a vendor's servers, with your source code along for the ride. Coder Agents run on infrastructure you control instead: your cloud VPC, on-prem, or fully air-gapped. Connect models from Anthropic, OpenAI, Google, or your own endpoint, and watch every running agent from a single view.

πŸ‘‰ Run agents on your own infrastructure

Copied

Your Users Type Sentences. Your Search Box Reads Keywords. (Sponsored)

A user types "waterproof jacket under $150 in size large" into your search box. A normal keyword search matches the words, not the meaning β€” so it ignores the price, skips the size, and returns a wall of results that aren't what they asked for.

Typesense is an open-source search engine that fixes this with built-in Natural Language Search. You add your LLM API key, and Typesense turns a plain-English query into the right filters, sorting, and ranking before it touches your data β€” no parsing logic for you to write.

It's typo-tolerant and runs in memory, so results appear as users type. It's the open-source alternative to Algolia, with a smaller learning curve than Elasticsearch, and it ships client libraries for .NET, Python, Go, and more. Self-host it with Docker or run it on Typesense Cloud, and drop it into a Laravel Scout or Django app without heavy setup.

See the Natural Language Search demo on GitHub and try it in your own stack.

πŸ‘‰ See Typesense on GitHub

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.

Copied

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:

csharp
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:

csharp
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:

csharp
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...

Copied

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:

csharp
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.

Copied

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.EntityFrameworkCore and on the DbContext. (This is the change.)
  • Infrastructure still owns the concrete DbContext configuration, 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:

csharp
internal 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:

csharp
internal 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.

Copied

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>.

csharp
public 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.

Copied

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.

Copied

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 custom IShipmentRepository, no IUnitOfWork.
  • Infrastructure owns the DbContext configuration, 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.

Whenever you're ready, here's how I can help you:

The .NET Senior Playbook is built to:

  • Fast-track you from junior or mid-level to senior
  • Keep you growing as a senior
  • Help you beat any .NET interview

Covers everything: C#, ASP.NET Core, EF Core, system design β€” answer each question first, reveal the solution, and a test after every chapter proves it stuck. Finish, and you earn a verifiable certificate for your LinkedIn.

The .NET Senior Playbook
View the Playbook

Enjoyed this article? Share it with your network

Improve Your .NET and Architecture Skills

Join my community of 25,000+ developers and architects.

Each week you will get 1 practical tip with best practices and real-world examples.

Learn how to craft better software with source code available for my newsletter.

Join 25,000+ developers already reading
No spam. Unsubscribe any time.