Skip to main content
SEMastery
Data Accessintermediate

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.

12 min readUpdated October 9, 2025

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

You
Middleman
Shopkeeper

Steps

1

You

Give the list

2

Middleman

Repeats the list

3

Shopkeeper

Picks items, you pay once

A thin repository often just repeats what the DbContext already does.

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 wantedEF Core gives youWhat it does
A repositoryDbSet<T>A collection-like surface to query, add, and remove one entity type
A unit of workDbContextTracks all your changes and saves them together
Save everything togetherSaveChanges()Commits all tracked changes in one transaction
Find by idFindAsync(id)Looks in memory first, then the database
Add / removeAdd() / 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.

The DbContext already plays both roles you were trying to build.

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

Controller
Repository
DbSet
Database

Steps

1

Direct

Controller to DbSet to DB

2

Wrapped

Controller to Repo to DbSet to DB

3

Result

Same data, extra layer

Direct EF Core skips a whole layer that copies the same calls.

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.

A leaky repository: the abstraction promises to hide EF Core, but EF Core leaks through IQueryable.

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:

ApproachWhat you getTrade-off
Mock a repositoryFast, no DBTests your mock, not your real SQL
EF Core in-memory providerFast, no DBBehaves unlike a real DB, hides bugs
Testcontainers (real DB in Docker)Real SQL, real constraintsNeeds Docker, a bit slower
SQLite file or shared in-memoryClose to real, simpleSome 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
    }
}
Testing flow with a real database container instead of a mocked repository.

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.
SituationUse direct DbContext?Use a repository?
Simple CRUD appYesNo, it just adds noise
One database, EF Core everywhereYesUsually no
Reused complex queriesMaybeYes, or a specification
Multiple data sourcesNoYes
Strict DDD with aggregatesNoYes, around aggregate roots
Application must not see EF CoreNoYes, with a domain interface

Decision flow

Start
Simple CRUD?
Many sources or DDD?
Decide

Steps

1

Simple CRUD?

Yes: use DbContext directly

2

Many sources or DDD?

Yes: a repository helps

3

Reused queries?

Use a specification

4

Decide

Add abstraction only when needed

A quick way to decide whether a repository is worth it.

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 DbContext already implements the unit of work pattern, and each DbSet<T> already is a repository. Microsoft says so themselves.
  • A repository that only forwards calls to the DbContext adds 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 IDbContextFactory over 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

Related Posts