Why You Don't Need a Repository in EF Core (For Most Apps)
EF Core already gives you a repository and unit of work. Learn why an extra repository layer is often just busywork, and when it actually helps.
When people start learning Entity Framework Core, one of the first "best practices" they hear is: wrap everything in a repository. So they create a UserRepository, a ProductRepository, an OrderRepository, and each one mostly forwards calls to EF Core. The code grows, but it does not get better.
This article explains a simple idea that surprises many students: EF Core already is a repository. For most apps, adding your own repository on top is extra work that gives you very little back. We will also look at the cases where a repository really does help, so you know when to use one.
A simple everyday picture
Imagine you go to a kirana shop (a small neighbourhood grocery store). You hand the shopkeeper a list: "two kilos of rice, one packet of biscuits, half a litre of oil." The shopkeeper walks to the shelves, picks each item, and puts them in your bag. At the end, you pay once at the counter.
Now imagine you hire a middleman who stands between you and the shopkeeper. You tell the middleman your list. The middleman repeats the exact same list to the shopkeeper. The shopkeeper gives the items to the middleman. The middleman hands them to you. You still pay once.
What did the middleman add? Nothing. He just repeated your words. He slowed things down and gave you one more person to manage.
In EF Core, the DbContext is the shopkeeper. It already knows how to fetch items (DbSet), and it lets you pay once at the end (SaveChanges). A repository that only forwards calls to the DbContext is that useless middleman. The shopkeeper already does the job well.
The useless middleman
Steps
You
Give the list
Middleman
Repeats the list
Shopkeeper
Picks items, you pay once
What is the repository pattern, really?
The repository pattern is an old, respected idea. A repository is an object that acts like an in-memory collection of your data. You ask it for things ("give me the user with id 5"), you add things, you remove things. Behind the scenes it talks to the database, but the rest of your code does not know or care how.
A unit of work is its partner. It keeps track of everything you changed during one piece of work, and then saves it all together in one go. Either everything is saved, or nothing is. This keeps your data safe and consistent.
These two patterns are genuinely good. The question is not "are they good?" The question is: do you need to build them yourself when EF Core already gives them to you?
EF Core already gives you both
Here is the key fact, and it comes straight from Microsoft's own documentation. The EF Core DbContext class is built on the unit of work and repository patterns.
| You wanted | EF Core gives you | What it does |
|---|---|---|
| A repository | DbSet<T> | A collection-like surface to query, add, and remove one entity type |
| A unit of work | DbContext | Tracks all your changes and saves them together |
| Save everything together | SaveChanges() | Commits all tracked changes in one transaction |
| Find by id | FindAsync(id) | Looks in memory first, then the database |
| Add / remove | Add() / Remove() | Stages an insert or delete |
When you write db.Users, that DbSet<User> is a repository for users. When you call db.SaveChangesAsync(), that is a unit of work commit. Microsoft designed it this way on purpose so you can use it directly from your code, such as from an ASP.NET Core controller.
Look at how short and clear direct EF Core code is.
public class OrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db) => _db = db;
public async Task<Order?> GetOrderAsync(int id)
{
// db.Orders IS the repository. No wrapper needed.
return await _db.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id);
}
public async Task PlaceOrderAsync(Order order)
{
_db.Orders.Add(order);
// SaveChanges IS the unit of work commit.
await _db.SaveChangesAsync();
}
}Now compare it with the "wrapped" version many tutorials teach.
// A repository that adds nothing useful.
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public OrderRepository(AppDbContext db) => _db = db;
public Task<Order?> GetByIdAsync(int id) =>
_db.Orders.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id);
public void Add(Order order) => _db.Orders.Add(order);
public Task<int> SaveAsync() => _db.SaveChangesAsync();
}Read both again. The second one is longer, has an interface to maintain, and does the exact same thing. That is the middleman from the kirana shop. Every new query means a new method on the interface and a new method in the class. The repository grows fat, and you spend your time feeding the middleman instead of helping the customer.
Two ways to fetch an order
Steps
Direct
Controller to DbSet to DB
Wrapped
Controller to Repo to DbSet to DB
Result
Same data, extra layer
The hidden cost: a leaky abstraction
Many people add a repository to "hide" EF Core. The idea is: if I hide EF Core behind an interface, I can swap it out later. This sounds smart. In practice it usually leaks.
Here is the trap. To keep your repository flexible, you often expose IQueryable<T>:
public interface IUserRepository
{
IQueryable<User> Query();
}But the moment you return IQueryable<User>, every caller starts chaining Where(), Include(), Select(), and OrderBy() on it. Those calls are translated by EF Core. The expression tree they build is understood by EF Core, not by some generic data layer. So you did not hide EF Core at all. You just spread it everywhere while pretending it was hidden. That is what we call a leaky abstraction: it promises to seal something off but the details leak right through.
If you truly hide EF Core (return plain List<User> from fixed methods), then you lose EF Core's best feature: composable queries that only run the exact SQL you need. You cannot win both ways with a thin wrapper.
The "I can swap the database" myth
A common reason given for repositories is: "What if we change from SQL Server to PostgreSQL one day?" Here is the calm truth. EF Core already supports many database providers. To switch, you usually change the connection setup, not your query code. You rarely need a custom repository for this.
And honestly, most apps never switch databases. Building a heavy abstraction today to protect against a change that may never come is like buying a snow shovel in a city that never sees snow. It feels responsible. It is mostly wasted effort.
"But how do I test without a repository?"
This is the strongest-sounding argument, so let us look at it carefully. People say: "If I have a repository interface, I can mock it in tests." True. But there is a better path that gives you more confidence.
The old advice was to use the EF Core in-memory provider for tests. The problem: it is not a real database. It does not enforce real constraints, it handles relationships and SQL differently, and it can let buggy code pass tests that would fail against a real database. So your green tests lie to you.
The modern, trusted approach is to test against a real database that starts and stops automatically:
| Approach | What you get | Trade-off |
|---|---|---|
| Mock a repository | Fast, no DB | Tests your mock, not your real SQL |
| EF Core in-memory provider | Fast, no DB | Behaves unlike a real DB, hides bugs |
| Testcontainers (real DB in Docker) | Real SQL, real constraints | Needs Docker, a bit slower |
| SQLite file or shared in-memory | Close to real, simple | Some SQL features differ |
With Testcontainers, you spin up a real database in a container for your tests, run your actual EF Core code against it, and throw it away when done. You catch real bugs. You do not need a repository layer just to make testing possible.
// A test using a real database via Testcontainers.
public class OrderTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _db =
new PostgreSqlBuilder().Build();
public async Task InitializeAsync() => await _db.StartAsync();
public async Task DisposeAsync() => await _db.DisposeAsync();
[Fact]
public async Task PlaceOrder_SavesToDatabase()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_db.GetConnectionString())
.Options;
await using var ctx = new AppDbContext(options);
await ctx.Database.MigrateAsync();
var service = new OrderService(ctx);
await service.PlaceOrderAsync(new Order { Id = 1 });
var saved = await ctx.Orders.FindAsync(1);
Assert.NotNull(saved); // real DB confirms it
}
}So when DO you need a repository?
This article is not saying "never use a repository." That would be silly. Good engineering is about choosing the right tool. Here are the honest cases where a repository earns its keep.
- Multiple data sources. If one "Customer" is built from a SQL table plus a cache plus an external API, a repository is a great place to hide that mess behind one clean method.
- Rich domain models (DDD). In Domain-Driven Design, a repository works at the level of an aggregate root. It loads and saves a whole consistent group of objects, and it keeps the domain layer free of EF Core. Here the repository has a real job, not just forwarding.
- Queries you reuse everywhere. If the same complex filter ("active customers in this region who ordered this month") appears in ten places, you want one home for it. A repository method, or better, the specification pattern, removes that duplication.
- You must hide the data tech. Some teams have a hard rule that the application layer must not reference EF Core at all. A repository (with a domain-owned interface) enforces that boundary.
| Situation | Use direct DbContext? | Use a repository? |
|---|---|---|
| Simple CRUD app | Yes | No, it just adds noise |
| One database, EF Core everywhere | Yes | Usually no |
| Reused complex queries | Maybe | Yes, or a specification |
| Multiple data sources | No | Yes |
| Strict DDD with aggregates | No | Yes, around aggregate roots |
| Application must not see EF Core | No | Yes, with a domain interface |
Decision flow
Steps
Simple CRUD?
Yes: use DbContext directly
Many sources or DDD?
Yes: a repository helps
Reused queries?
Use a specification
Decide
Add abstraction only when needed
A better middle ground: the specification pattern
If your only real problem is "I keep repeating the same complex query," you do not need a full repository. You can use a specification: a small object that holds one reusable query rule. You build the rule once and apply it wherever you need it. This removes duplication without locking you into a fat repository. Many teams use direct EF Core for everyday work and reach for specifications only when a query starts appearing in many places.
The rule of thumb
Start simple. Use DbContext and DbSet directly. Your code will be short, clear, and easy to read. Add a repository only when you feel real pain that it solves, such as multiple data sources or a strict domain boundary. Do not add it "just in case." Adding layers you do not need is one of the most common ways junior code becomes hard to maintain.
A kind way to remember it: do not hire a middleman to repeat your grocery list. Talk to the shopkeeper directly. Hire help only when the job is genuinely too big for one person.
Quick recap
- EF Core's
DbContextalready implements the unit of work pattern, and eachDbSet<T>already is a repository. Microsoft says so themselves. - A repository that only forwards calls to the
DbContextadds ceremony, not value. It is the useless middleman. - Hiding EF Core behind
IQueryable<T>is a leaky abstraction: EF Core's query semantics leak straight through to callers. - The "swap the database later" reason rarely happens, and EF Core's provider system handles most of it anyway.
- For testing, prefer a real database via Testcontainers or
IDbContextFactoryover mocks or the in-memory provider, which can hide real bugs. - Repositories still earn their place with multiple data sources, DDD aggregates, heavily reused queries, or a strict no-EF-Core boundary.
- For reused queries alone, a specification is a lighter choice than a full repository.
- Start with direct EF Core. Add abstraction only when real pain demands it.
References and further reading
- Microsoft Learn: Implementing the infrastructure persistence layer with EF Core
- Anton DevTips: Why You Don't Need a Repository in EF Core
- codewithmukesh: Repository Pattern in .NET 10, Do You Really Need It?
- Steven Giesel: Repository Pattern, a controversy explained
- Gunnar Peipman: No need for repositories and unit of work with EF Core
Related Posts
Understanding Change Tracking for Better Performance in EF Core
Learn how EF Core change tracking works, the entity states it uses, and simple tricks like AsNoTracking to make your .NET apps faster.
Calling Views, Stored Procedures and Functions in EF Core
A friendly, beginner guide to calling database views, stored procedures, and functions in EF Core with FromSql, SqlQuery, ExecuteSql, and ToView.
How to Use Global Query Filters in EF Core (Beginner Guide)
Learn EF Core global query filters with simple examples for soft delete and multi-tenancy, plus the new named filters in EF Core 10.
EF Core Bulk Insert: Boost Performance with Entity Framework Extensions
Learn how EF Core bulk insert with Entity Framework Extensions saves data faster, using simple examples, diagrams, and clear performance comparisons.
How to Use EF Core Interceptors: A Beginner-Friendly Guide
Learn EF Core interceptors step by step. Add auditing, soft delete, logging, and timing to your DbContext with clean, reusable code and zero clutter.
Getting Started With MongoDB in EF Core: A Beginner's Guide
A friendly beginner guide to using MongoDB with EF Core in .NET. Learn setup, DbContext, UseMongoDB, CRUD, mapping, and the limits you must know.