DbContext vs. Repository + Unit of Work: Do You Need Both?

DbSet<T> is already a generic Repository, and DbContext is already a Unit of Work. Why wrapping EF Core in your own layers often doesn't deliver what it promises — and the cases where it still does.

This debate resurfaces every few months — on LinkedIn, in a call with a coworker, in a code review. Someone opens a PR with IRepository<T> and IUnitOfWork on top of EF Core. Someone else asks "why, DbContext already does that." From there the thread splits into two camps that rarely convince each other.

I went looking for an actual answer instead of picking a side by gut feeling. Here's where I landed.


#What EF Core already gives you

Two things, out of the box.

DbSet<T> is a generic Repository. Add, Remove, Find, and filtering through IQueryable — that's the same surface a hand-rolled IRepository<T> exposes, minus the extra file.

CSHARP
public class AppDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Customer> Customers => Set<Customer>();
}

// no repository needed for this
var activeOrders = await context.Orders
    .Where(o => o.Status == OrderStatus.Active)
    .ToListAsync();

DbContext is a Unit of Work. It tracks every change made through its DbSets and commits them together with a single SaveChangesAsync(), inside one transaction.

CSHARP
context.Orders.Add(order);
context.Inventory.Update(stock);
await context.SaveChangesAsync(); // both changes, one transaction

So before adding IRepository<T> and IUnitOfWork on top, it's worth asking: what is this new layer actually adding that isn't already there?


#The two arguments that don't hold up

"Repository makes testing easier." In practice, mocking IRepository<T> means faking LINQ query behavior by hand — Where, Include, OrderBy — which is exactly what EF Core's own providers already do for you. The InMemory provider, or SQLite in-memory mode, let you test against a real DbContext, with real query translation, without touching a database server. A hand-rolled repository interface often ends up less faithful to production behavior than testing against EF Core directly.

"Repository lets you swap databases or ORMs later." In practice, I've never seen a team actually replace EF Core with another ORM on an existing project. And when the repository interface exposes IQueryable<T> — which most do, because someone eventually needed filtering or paging — the abstraction was already leaking EF Core specifics through it. You didn't decouple from the ORM; you just added a layer between you and it.

Neither argument is fake. They're just already answered by EF Core itself, which makes the extra layer redundant rather than wrong.


#Where Repository + Unit of Work still earn their place

The pattern isn't broken — it's usually applied wrong. It stops being noise the moment the interface stops being a copy of DbSet<T>.

When the contract is domain-specific, not generic CRUD.

CSHARP
// Adds nothing over calling context.Customers directly
public interface ICustomerRepository
{
    Task<Customer?> GetByIdAsync(Guid id);
    Task AddAsync(Customer customer);
}

// Adds something: a query the domain actually needs, named for what it means
public interface ICustomerRepository
{
    Task<Customer?> FindActiveByEmailAsync(string email);
    Task<IReadOnlyList<Customer>> FindOverdueAccountsAsync();
}

The second version isn't hiding EF Core — it's expressing a domain concept. That's worth a real interface.

When there's a real architectural boundary to protect. In Clean or Hexagonal architecture, hiding EF Core behind an interface usually isn't about swapping it out later — it's that the domain layer, by design, must not reference infrastructure types at all. That's an architectural rule, not a testability or portability argument, and it's a legitimate reason on its own.

When a Unit of Work coordinates more than one DbContext or data store. If a single business operation genuinely needs to commit against two different data sources together, DbContext.SaveChangesAsync() alone doesn't cover that. That's the one case where a Unit of Work does real coordination work instead of duplicating what EF Core already does.


#The question that actually decides it

Not "Repository and Unit of Work: yes or no." The question is: does this interface say more than DbSet<T> already does?

If ICustomerRepository has the same five methods as every other I*Repository<T> in the codebase, it's ceremony — delete it and inject AppDbContext directly. If it expresses something specific to the domain, or protects a boundary you've deliberately decided to enforce, keep it.

Default to DbContext directly. Add a repository for a specific reason, not out of habit applied to every project the same way.

#ef-core #dotnet #repository-pattern #unit-of-work