Most senior developers will tell you to wrap EF Core inside your own repository interfaces.
But have you ever wondered: why do we need a repository on top of a repository?
In real-world projects, this advice often results in writing a lot of redundant boilerplate code and leads to over-engineered solutions.
Each feature now takes more time to implement and maintain than it should.
There is a better way.
In this post, we will explore:
- Why You Don't Need a Repository in EF Core
- How to Use EF Core Without a Repository
- Best Practices for EF Core in:
- N-Layered Applications
- Clean Architecture (pragmatic version)
- Vertical Slice Architecture
- Using Specification Pattern for Query Reuse
- How This Approach Affects Testability
- When You Might Still Need a Repository
Let's dive in!
Why You Don't Need a Repository in EF Core
One of the most common problems is that Repositories tend to grow rapidly as business requirements evolve.
When your application is small, using the Repository Pattern seems easy.
What starts as a simple CRUD operation with 4 methods quickly becomes a large class with database read and write queries for all possible cases.
As your domain grows, you face a critical decision: should you create a Repository for each entity?
Every new business requirement means adding another method to the Repository. Over time, you end up with classes full of similar methods:
csharppublic class ShipmentRepository { public Task<List<Shipment>> GetShipmentsByOrder(int userId) { ... } public Task<List<Shipment>> GetCancelledPosts() { ... } public Task<List<Shipment>> GetDeliveredShipmentsByCategory(string category) { ... } public Task<List<Shipment>> GetRecentShipments(int daysBack) { ... } // ...and many more! }
It gets harder to find the correct method or even remember what's already in every repository.
What if you have multiple entities?
When dealing with Shipments, ShipmentItems, and Orders, following the traditional approach leads to:
csharppublic interface IShipmentRepository { Task<ShipmentDto> GetByIdAsync(int id); Task<IEnumerable<ShipmentDto>> GetAllAsync(); // ... } public interface IShipmentItemRepository { Task<ShipmentItemDto> GetByIdAsync(int id); Task<IEnumerable<ShipmentItemDto>> GetByShipmentIdAsync(int shipmentId); // ... } public interface IOrderRepository { Task<OrderDto> GetByIdAsync(int id); Task<IEnumerable<OrderDto>> GetByUserIdAsync(int userId); // ... }
But what happens when you need to fetch related entities together? For example:
- Get a shipment with all its items
- Get an order with its associated shipments
- Get shipment historical data that loads multiple entities
Where do these cross-entity methods belong?
Many developers end up with a lot of repositories that don't do enough. And when you implement a new feature, you start thinking about where to add a new method in one of N repositories.
I often hear these common justifications for Repositories:
1. "We may switch databases later"
This is the most common argument.
But how often do you really switch from one database to another in production?
In 99% of cases, you won't need to switch the database. However, even if you switch from one SQL database to another (for example, Postgres β SQL Server), 95%+ of your code in EF Core won't change.
If you use stored procedures and triggers, it will require a much bigger rewrite in the database itself (I hope you don't use them).
Also, switching from a relational database to a document database means changing your data model, queries, and access patterns. You can't just swap repository implementations.
In practice:
- Switching from SQL Server to PostgreSQL? EF Core supports both.
- Switching to Cosmos DB or MongoDB? That's a complete rewrite of data access logic, not just a change in repositories.
- So unless you're actively building a multi-database abstraction layer (which most apps don't need), this reasoning doesn't hold.
2. "It makes testing easier"
Some argue that mocking a repository is easier than mocking a DbContext.
But this hides a bigger problem: you're testing an abstraction of an abstraction.
Mocking repositories often leads to fragile tests that don't reflect real query behavior. For example, if you mock a repository method that returns shipments, you don't test how EF Core translates your LINQ to SQL or how it handles loading child entities.
Instead, it's better to use real EF Core with an in-memory database or, even better, write integration tests.
3. "It enforces separation of concerns"
Repositories are often used in N-Layered and Clean Architectures to keep the business layer decoupled from EF Core.
But in practice, this separation creates more confusion than clarity.
You start with one repository per entity. But as your features grow, you need data that touches multiple entities.
Now you're forced to:
- Inject multiple repositories into your services
- Or move cross-entity logic into a fat repository
Instead of clean separation, you get more indirection, more boilerplate, and harder-to-follow code.
How to Use EF Core Without a Repository
EF Core's DbContext already implements the Repository and Unit of Work patterns, as stated in the official DbContext's code summary.
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
When you need to find all shipments for an order, you write code like this:
csharpvar shipments = await dbContext.Shipments .Include(s => s.Items) .Where(s => s.OrderId == orderId) .ToListAsync();
It's super simple and straightforward. You don't need a repository to query this data.
What if you need to get order shipments from a few use cases? Just duplicate this simple query in a few places, not a big deal.
But what if you have a more complex query?
You can always extract it as an extension method to DbSet<Shipment> and reuse it more conveniently:
csharpvar shipments = await dbContext.Shipments .GetActiveShipmentsForOrder(orderId) .ToListAsync();
Inject DbContext in Your Services or Use Cases
Instead of injecting IShipmentRepository, IShipmentItemRepository, and IOrderRepository, just inject DbContext.
csharpinternal sealed class CreateShipmentCommandHandler( ShipmentsDbContext context, ILogger<CreateShipmentCommandHandler> logger) : IRequestHandler<CreateShipmentCommand, ErrorOr<ShipmentResponse>> { public async Task<ErrorOr<ShipmentResponse>> Handle( CreateShipmentCommand request, CancellationToken cancellationToken) { var shipmentExists = await context.Shipments .AnyAsync(x => x.OrderId == request.OrderId, cancellationToken); if (shipmentExists) { return Error.Conflict("Shipment already exists"); } var shipment = request.MapToShipment(); await context.Shipments.AddAsync(shipment, cancellationToken); await context.SaveChangesAsync(cancellationToken); return shipment.MapToResponse(); } }
This code is focused, testable, and easy to follow.
Using EF Core Directly in N-Layered Applications
The N-Layered Architecture is still very popular across codebases.
It's built around separating responsibilities into logical layers, most often:
- Presentation Layer: Controllers, API endpoints, or UI components
- Business Logic Layer (Service): Application services encapsulating business rules
- Data Access Layer (Repository): Abstraction over data persistence
In N-Layered architecture, your Application Layer should contain the business logic and use cases. It should coordinate workflows, enforce policies, and call the domain model or infrastructure.
Here is a classic implementation example of ShipmentService:
csharppublic sealed class ShipmentService( IShipmentRepository repository, ILogger<ShipmentServiceWithRepo> logger) { public async Task<ErrorOr<ShipmentResponse>> CreateAsync( CreateShipmentCommand request, CancellationToken token = default) { var alreadyExists = await repository.ExistsForOrderAsync(request.OrderId, token); if (alreadyExists) { logger.LogInformation("Shipment for order '{OrderId}' already exists", request.OrderId); return Error.Conflict($"Shipment for order '{request.OrderId}' is already created"); } var shipmentNumber = new Faker().Commerce.Ean8(); var shipment = request.MapToShipment(shipmentNumber); await repository.AddAsync(shipment, token); await repository.SaveChangesAsync(token); logger.LogInformation("Created shipment: {@Shipment}", shipment); return shipment.MapToResponse(); } public async Task<ErrorOr<ShipmentResponse>> GetByIdAsync( Guid shipmentId, CancellationToken token = default) { var shipment = await repository.GetByIdAsync(shipmentId, token); if (shipment is null) { return Error.NotFound($"Shipment '{shipmentId}' not found"); } return shipment.MapToResponse(); } }
Calling infrastructure services doesn't mean you need to hide EF Core behind a repository. Instead, you can inject your DbContext directly into application services or handlers:
csharppublic sealed class ShipmentService( ShipmentsDbContext context, ILogger<ShipmentService> logger) { public async Task<ErrorOr<ShipmentResponse>> CreateAsync( CreateShipmentCommand request, CancellationToken token = default) { var shipmentAlreadyExists = await context.Shipments .AnyAsync(x => x.OrderId == request.OrderId, token); if (shipmentAlreadyExists) { logger.LogInformation("Shipment for order '{OrderId}' is already created", request.OrderId); return Error.Conflict($"Shipment for order '{request.OrderId}' is already created"); } var shipmentNumber = new Faker().Commerce.Ean8(); var shipment = request.MapToShipment(shipmentNumber); await context.Shipments.AddAsync(shipment, token); await context.SaveChangesAsync(token); logger.LogInformation("Created shipment: {@Shipment}", shipment); return shipment.MapToResponse(); } public async Task<ErrorOr<ShipmentResponse>> GetByIdAsync( Guid shipmentId, CancellationToken token = default) { var shipment = await context.Shipments .Include(s => s.Items) .FirstOrDefaultAsync(s => s.Id == shipmentId, token); if (shipment is null) { return Error.NotFound($"Shipment '{shipmentId}' not found"); } return shipment.MapToResponse(); } }
Does the code become harder? Absolutely not.
Instead, this approach will save you time:
- From creating a new repository method each time
- From navigating from service to repository back and forward to see the full implementation
When you find that a query is reused in multiple places:
- Extract the query into a shared class or method
- Use extension methods, expression extensions, or specifications (we'll cover this later)
Using EF Core Directly in Clean Architecture and Vertical Slice Architecture
Using EF Core Directly in Clean Architecture
Clean Architecture aims to separate the application's concerns into distinct layers, promoting high cohesion and low coupling.
It consists of the following layers:
- Domain: contains core business objects such as entities.
- Application: implementation of business use cases (like the Service Layer in N-Layered).
- Infrastructure: implementation of external dependencies like database, cache, message queue, authentication provider, etc.
- Presentation: implementation of an interface with the outside world, like WebApi, gRPC, GraphQL, MVC, etc.
But as time has passed, Clean Architecture has evolved into a more Pragmatic approach: where developers agreed that they can use EF Core inside the Application use cases.
Here is how the CreateShipmentCommandHandler changes when we get rid of repositories:
csharpinternal sealed class CreateShipmentCommandHandler( ShipmentsDbContext context, ILogger<CreateShipmentCommandHandler> logger) : IRequestHandler<CreateShipmentCommand, ErrorOr<ShipmentResponse>> { public async Task<ErrorOr<ShipmentResponse>> Handle( CreateShipmentCommand request, CancellationToken cancellationToken) { var shipmentAlreadyExists = await context.Shipments.AnyAsync(x => x.OrderId == request.OrderId, cancellationToken); if (shipmentAlreadyExists) { logger.LogInformation("Shipment for order '{OrderId}' is already created", request.OrderId); return Error.Conflict($"Shipment for order '{request.OrderId}' is already created"); } var shipmentNumber = new Faker().Commerce.Ean8(); var shipment = request.MapToShipment(shipmentNumber); await context.Shipments.AddAsync(shipment, cancellationToken); await context.SaveChangesAsync(cancellationToken); logger.LogInformation("Created shipment: {@Shipment}", shipment); var response = shipment.MapToResponse(); return response; } }
Some developers will argue: "But Clean Architecture means my Application Layer shouldn't depend on EF Core!"
That's only true if you interpret it in the strictest sense. In a pragmatic Clean Architecture, EF Core is not "just another ORM" β it's the persistence technology you chose.
Yes, you don't need a repository abstraction to preserve the Clean Architecture boundaries.
By using DbContext directly, you reduce boilerplate while keeping your core Domain free of EF dependencies.
If one day you replace EF Core, it's not just repositories you'll rewrite β it's your entire persistence logic.
That's why using EF Core directly in the application use cases is a trade-off that gives more advantages than disadvantages.
Using EF Core Directly in Vertical Slice Architecture
Vertical Slice Architecture focuses on features, not layers.
I believe that the natural evolution of Clean Architecture, with its feature folders, has led to its transformation into Vertical Slice Architecture. Original Clean Architecture wasn't always about separating your solution into projects, but rather about your classes and their relationships.
The main point is that classes of inner layers can't call classes of outer layers. With Vertical Slice Architecture, you can achieve the same but with fewer projects.
I found that combining Clean Architecture with Vertical Slices is an excellent architecture design for complex applications. In small applications or in applications that don't have complex business logic, you can use Vertical Slices without a Clean Architecture.
As a core, I use Clean Architecture layers and combine them with Vertical Slices.
Here is how the layers are being modified:
- Domain: contains core business objects such as entities (remains unchanged).
- Infrastructure: implementation of external dependencies like database, cache, message queue, authentication provider, etc (remains unchanged).
- Application and Presentation Layers are combined with Vertical Slices.
And if we used EF Core directly in the application use cases of Clean Architecture, it would be essentially the same with Vertical Slice Architecture.
Using the Specification Pattern with EF Core
One of the options mentioned above to avoid code duplication when using EF Core directly is using the Specification Pattern.
The Specification Pattern is a way to describe what data you want from your database using small, reusable classes called "specifications".
Each Specification represents a filter or a rule that can be applied to a query. This lets you build complex queries by combining simple, easy-to-understand classes.
The Specification Pattern brings the following benefits to the table:
- Reusability: You can write a specification once and use it anywhere in your project.
- Combination: You can combine two or more specifications to make more advanced queries.
- Testability: Specifications are classes over EF Core (or any other ORM), so you can cover them with unit tests, or even better, integration tests.
- Separation of Concerns: Your query logic is separated from your data access code. This keeps things clean.
Instead of writing dozens of methods in your Repository, you can create new specifications as your requirements grow. You can then pass these specifications to your DbContext (or even a repository, if you still want to use one).
Here is an example of a Specification that returns viral posts in a social media application with at least 150 likes:
csharppublic class ViralPostSpecification : Specification<Post> { public ViralPostSpecification(int minLikesCount = 150) { AddFilteringQuery(post => post.Likes.Count >= minLikesCount); AddOrderByDescendingQuery(post => post.Likes.Count); } }
You can reuse this Specification anywhere in your code to get "viral" posts.
Okay, you might think - what's the point of creating Specifications? Isn't it another extra layer of abstraction?
Yes, but here are a few cases where Specifications can be useful:
- Extracting common complex queries into reusable classes
- Combining multiple queries into a single query
The real power lies in allowing multiple specifications to be combined dynamically based on input data.
Imagine a user selects several predefined filters, and you combine them dynamically with an AND or OR operation.
Learn more about the Specification Pattern in this article.
Testability with EF Core
A common reason developers give for creating repositories is testability.
The argument goes: "If I wrap EF Core in repositories, I can mock the repositories in unit tests."
But here's the reality:
- Mocking repositories usually leads to fake behavior that doesn't match EF Core
- Your tests become fragile and less valuable
- You don't actually test your queries, which is often the most important part
Instead of mocking, you should write real EF Core tests.
Option 1: Use EF Core InMemory Provider
EF Core has an in-memory database provider, which allows you to run tests without a physical database.
csharpvar options = new DbContextOptionsBuilder<ShipmentsDbContext>() .UseInMemoryDatabase("ShipmentsTestDb") .Options; using var context = new ShipmentsDbContext(options); // Arrange context.Shipments.Add(new Shipment { OrderId = Guid.NewGuid(), Address = "Berlin" }); await context.SaveChangesAsync(); // Act var shipment = await context.Shipments.FirstOrDefaultAsync(); // Assert Assert.NotNull(shipment);
This is fast and works well for many unit-test-like scenarios.
But keep in mind that the InMemory provider does not behave exactly like a relational database (e.g., it doesn't enforce foreign keys). Use it only for simple scenarios.
Option 2: Write Integration Tests
This is my favourite option.
Writing tests that talk to a real database is the best way to ensure that your application works as expected.
There are two main approaches for writing integration tests:
- Testing webapi calls to your application
- Testing your classes that use EF Core DbContext
In most cases, the first option is more than enough. But you can also test complex scenarios with EF Core directly.
Learn more about integration testing with EF Core in this article.
Why this is better than mocking repositories:
- Mocks lie: they don't replicate EF Core's LINQ-to-SQL translation, eager loading, or tracking behavior.
- Real EF Core tests catch real issues: like incorrect joins, bad projections, or missing Includes.
- Integration tests cover more: ensuring not only your code works, but also that your DB schema is correct.
By testing with EF Core directly, you don't lose testability β you actually gain reliability.
When You Might Still Need a Custom Repository
So far, we've argued that most of the time you don't need repositories with EF Core. But like with every rule, there are exceptions.
Here are cases where a custom repository can make sense:
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 it's 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 repositories for 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 these cases, repositories serve a specific purpose. But creating a repository for every single entity β just because "that's how we've always done it" β only leads to bloat and boilerplate.
Summary
Let's recap the key takeaways:
-
EF Core already implements Repository and Unit of Work.
DbSet<TEntity>is your repository.DbContext.SaveChangesAsync()is your unit of work. -
Repositories often add unnecessary complexity. They lead to fat repositories, too many small repositories, or duplicated queries across services.
-
Use EF Core directly in your application services, handlers, or vertical slices. This keeps your code simpler, more focused, and easier to maintain.
-
Use the Specification Pattern for query reuse. It avoids duplication and keeps complex queries composable.
-
Testing works well without repositories. Use EF Core InMemory or integration tests β instead of mocking repositories.
-
Repositories still have niche uses. For shared, complex queries, cross-cutting concerns, or multi-source data access, a repository can be useful.
In modern .NET applications, using EF Core directly in your application layer or vertical slices is often the cleanest, simplest, and most pragmatic choice.
Repositories are not dead β but they're no longer the default. Use them only when they truly add value.
There is no single correct way to write the software; you need to pick whatever works best in each particular project and case.
Hope you find this newsletter useful. See you next time.

