The Repository and Unit of Work Patterns in .NET Do You Still Need Them in 2026?
Does .NET still need the Repository and Unit of Work patterns in 2026, now that EF Core's DbContext already implements both? Here's exactly when to skip them and when to keep them."

If you’ve written .NET for more than a year, you’ve probably built an IRepository<T> at least once. You’ve probably also had someone on the team ask why you’re wrapping DbContext in another DbContext. That question has only gotten louder as EF Core has matured, and by 2026 it’s basically a rite of passage on every new codebase: do you actually need Repository and Unit of Work, or is DbContext enough on its own?
“It depends” is true but useless on its own. So this post works through why it depends: what Microsoft’s own architecture guidance says, and what EF Core actually does under the hood. Read to the end and you should be able to place your own project on one side of the line instead of shrugging “it depends” back at your team.
What problem were these patterns solving in the first place?
Before deciding whether you still need them, it helps to remember what problem they were solving:
- Separation of concerns. The Repository pattern keeps data-access code (queries,
SaveChanges, LINQ) out of your business logic, so a service class talks toIOrderRepository.GetByIdAsync()instead of_context.Orders.Where(...). - Transaction coordination. The Unit of Work pattern tracks every object touched by a business operation and commits (or rolls back) them as one atomic change, even when multiple repositories are involved.
- Abstraction over the data store. Both patterns hide the concrete persistence technology behind an interface, so the rest of the app depends on a contract, not on EF Core, Dapper, or a specific database.
- Testability. By depending on interfaces instead of a concrete
DbContext, business logic can be tested with a fake or mock repository instead of a real database.
Those are real problems. The question in 2026 isn’t whether they’re worth solving. It’s whether EF Core already solves them for you, and whether the classic hand-rolled implementation is still the best way to solve the ones it doesn’t.
What “EF Core already implements Unit of Work” actually means
A lot of blog posts get sloppy here, so it’s worth being precise. Microsoft’s own .NET Microservices architecture guide puts it carefully:
“The Entity Framework
DbContextclass is based on the Unit of Work and Repository patterns and can be used directly from your code… The Unit of Work and Repository patterns result in the simplest code, as in the CRUD catalog microservice in eShopOnContainers.” — Microsoft Learn, Infrastructure persistence layer with EF Core
DbContext is based on those patterns. It doesn’t say they’re irrelevant. In practice that breaks down like this:
DbSet<T>is your repository. It exposesAdd,Find,Remove, and a queryable surface per entity type, which is what a repository interface would give you minus the ceremony.DbContextis your Unit of Work. Its change tracker records every insert, update, and delete across everyDbSetyou’ve touched, andSaveChanges/SaveChangesAsynccommits them all in a single database transaction: succeed together, fail together. That’s not just an analogy, it’s how EF Core is actually built (EF Core docs — Saving Data, EF Core docs — Change Tracking).
Here’s what that looks like without any custom abstraction:
public class CustomerService
{
private readonly ApplicationDbContext _context;
public CustomerService(ApplicationDbContext context)
{
_context = context;
}
public async Task AddCustomerAsync(Customer customer)
{
await _context.Customers.AddAsync(customer);
await _context.SaveChangesAsync(); // the Unit of Work commit
}
public async Task<Customer?> GetCustomerByIdAsync(int id)
{
return await _context.Customers.FindAsync(id);
}
}
Register two changes, say deducting stock from a Product and creating an Order, in the same DbContext instance and call SaveChangesAsync() once, and EF Core wraps both in one transaction automatically. That’s the Unit of Work pattern, already implemented, for free.
So the real debate isn’t whether EF Core gives you these patterns. It does. It’s whether you should add a second, custom layer of Repository/Unit of Work interfaces on top of DbContext. That’s the actual question, and it’s what the rest of this post is about.
The classic hand-rolled implementation
For context, here’s the pattern most .NET tutorials teach: an IUnitOfWork coordinating multiple repositories.
public interface IUnitOfWork : IDisposable
{
ICustomerRepository Customers { get; }
IOrderRepository Orders { get; }
Task<int> CompleteAsync();
}
public class UnitOfWork : IUnitOfWork
{
private readonly ApplicationDbContext _context;
public ICustomerRepository Customers { get; }
public IOrderRepository Orders { get; }
public UnitOfWork(ApplicationDbContext context, ICustomerRepository customers, IOrderRepository orders)
{
_context = context;
Customers = customers;
Orders = orders;
}
public Task<int> CompleteAsync() => _context.SaveChangesAsync();
public void Dispose() => _context.Dispose();
}
public interface ICustomerRepository
{
Task<Customer?> GetByIdAsync(int id);
Task<IEnumerable<Customer>> GetAllAsync();
Task AddAsync(Customer customer);
void Update(Customer customer);
Task DeleteAsync(int id);
}
public class CustomerRepository : ICustomerRepository
{
private readonly ApplicationDbContext _context;
public CustomerRepository(ApplicationDbContext context)
{
_context = context;
}
public Task<Customer?> GetByIdAsync(int id) => _context.Customers.FindAsync(id).AsTask();
public Task<IEnumerable<Customer>> GetAllAsync() =>
_context.Customers.ToListAsync().ContinueWith(t => (IEnumerable<Customer>)t.Result);
public Task AddAsync(Customer customer) => _context.Customers.AddAsync(customer).AsTask();
public void Update(Customer customer) => _context.Customers.Update(customer);
public async Task DeleteAsync(int id)
{
var customer = await _context.Customers.FindAsync(id);
if (customer is not null)
{
_context.Customers.Remove(customer);
}
}
}
Notice the repository doesn’t call SaveChanges itself. That’s deliberate: keep Add/Remove in the repository and delegate the actual commit to the Unit of Work, so a handler can touch several repositories and still commit them as one transaction. If every repository calls SaveChanges internally, you’ve quietly lost the one thing Unit of Work exists to give you.
This is clean and testable, and it works. But it’s also, as written, mostly re-implementing what DbSet<T> and DbContext already do. So is it worth the extra interfaces and classes? That depends on how you’d actually use it.
Where the generic repository goes wrong
The most common failure mode isn’t “using a repository.” It’s using a generic one: IRepository<T> with GetAll(), GetById(), Add(), Update(), Delete(), applied uniformly to every entity regardless of what that entity actually needs. Three specific problems show up in practice.
1. It leaks its abstraction straight back out through IQueryable. The moment a repository method returns IQueryable<T> so callers can keep composing .Where() and .Include(), you’ve defeated the point of the abstraction: callers are back to writing EF-Core-shaped LINQ, just through an extra layer of indirection. Microsoft’s own architecture guide flags this directly, in the specification-pattern section: “we don’t recommend returning IQueryable from a repository.” (Microsoft Learn — Implement the Query Specification pattern)
2. It costs you performance you’d get for free with DbContext directly. A generic repository typically returns full entities, which then get mapped to a DTO in a service layer: one extra allocation, and every column travels over the wire even when the caller only needed three fields. Compare:
// Through a generic repository: loads the full entity, maps afterward
var customer = await _customerRepository.GetByIdAsync(id);
var dto = new CustomerSummaryDto(customer.Id, customer.Name, customer.Email);
// Directly against DbContext: projects in SQL, only the needed columns travel
var dto = await _context.Customers
.Where(c => c.Id == id)
.Select(c => new CustomerSummaryDto(c.Id, c.Name, c.Email))
.AsNoTracking()
.FirstOrDefaultAsync();
The second version lets EF Core translate the Select into SQL, so the database only ever sends back the three columns you asked for. The repository version can’t do that: GetByIdAsync doesn’t know what the caller actually needs, so it has to hand back the whole entity every time. On a hot path, that difference in bytes-over-the-wire and allocations adds up.
3. It rarely delivers the ORM independence it’s sold on. The classic justification, “if we ever swap EF Core for Dapper or MongoDB, we only change the repository,” almost never plays out in practice. Query logic (Includes, filters, projections) is usually tied to the specific ORM’s capabilities, so swapping the underlying technology tends to mean rewriting the queries anyway, repository or not.
None of this means Repository is always wrong. It means the generic, CRUD-everything version is where teams get burned. A narrower, purposeful repository is a different story.
Where Repository and Unit of Work still earn their place
Take the generic CRUD wrapper out of the picture, and there are still real, recurring scenarios where a custom repository (and sometimes an explicit IUnitOfWork) is the right call. Not “legacy code” hand-waving, but specific technical reasons:
- Domain-Driven Design with aggregate roots. When an aggregate (say,
Orderwith itsOrderItems) owns invariants that must never be violated, a repository scoped to that aggregate, not a genericDbSetwrapper, is the natural boundary for “load the whole aggregate, save the whole aggregate.” This is precisely the approach Microsoft uses for eShopOnContainers in its own reference architecture. - Clean Architecture’s dependency rule. If your domain and application layers are meant to have zero reference to EF Core, a repository interface defined in the domain layer (implemented in infrastructure) is what makes that boundary real instead of aspirational.
- Coordinating multiple repositories in one transaction. This is the actual reason to reach for an explicit
IUnitOfWorkrather than relying on implicitDbContextsharing: a handler that mutates two or three aggregates and needs one atomic commit across them. - Testing business logic without a database. Mocking
IOrderRepositoryto unit-test a pricing rule or a validation branch is simpler and faster than spinning up a database, as long as you’re testing orchestration logic, not the query itself (more on that distinction below). - Multiple physical data sources behind one logical entity. Combining a SQL table with a cache or an external API behind one repository interface, so callers don’t need to know the difference.
Microsoft’s own guidance frames the tradeoff plainly: use DbContext directly for the simplest code, and add custom repositories “when implementing more complex microservices or applications” specifically because they decouple the persistence layer and make mocking easier (Microsoft Learn).
The middle ground: the Specification pattern
If your actual complaint about Repository is “I need to reuse a filtered, paged, eager-loaded query across a few places without exposing raw IQueryable,” you don’t need a bigger repository interface. You need the Specification pattern, which encapsulates a query as an object instead of a growing pile of method overloads. Microsoft documents this directly in its architecture guide as a companion to the Repository pattern:
public interface ISpecification<T>
{
Expression<Func<T, bool>> Criteria { get; }
List<Expression<Func<T, object>>> Includes { get; }
}
public class BasketWithItemsSpecification : ISpecification<Basket>
{
public Expression<Func<Basket, bool>> Criteria { get; }
public List<Expression<Func<Basket, object>>> Includes { get; } = new();
public BasketWithItemsSpecification(int basketId)
{
Criteria = b => b.Id == basketId;
Includes.Add(b => b.Items);
}
}
// used through a repository, without leaking IQueryable
public IEnumerable<T> List(ISpecification<T> spec)
{
var query = spec.Includes.Aggregate(
_context.Set<T>().AsQueryable(),
(current, include) => current.Include(include));
return query.Where(spec.Criteria).AsEnumerable();
}
This gives you the query-reuse benefit people usually reach for a generic repository to get, without the IQueryable leak or the one-method-per-filter sprawl. See Microsoft’s full walkthrough in Implement the Query Specification pattern.
How to actually test data access in 2026
One argument for Repository that’s aged the worst is “you need it to unit test the database layer.” Mocking a repository interface tests your orchestration logic, not your actual queries. A missing .Include(), a bad Where translation, or a cartesian-product bug will pass every mocked test and then show up in production instead.
A more reliable approach is to run integration tests against a real database engine using Testcontainers: spin up a real SQL Server or PostgreSQL instance in Docker for the test run, point EF Core at it, run your actual migrations, and tear it down when the tests finish.
public class OrderRepositoryTests : IAsyncLifetime
{
private readonly MsSqlContainer _dbContainer = new MsSqlBuilder().Build();
public async Task InitializeAsync() => await _dbContainer.StartAsync();
public async Task DisposeAsync() => await _dbContainer.DisposeAsync();
[Fact]
public async Task AddAsync_persists_a_new_order()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlServer(_dbContainer.GetConnectionString())
.Options;
await using var context = new ApplicationDbContext(options);
await context.Database.MigrateAsync();
var repository = new OrderRepository(context);
await repository.AddAsync(new Order { /* ... */ });
await context.SaveChangesAsync();
Assert.Equal(1, await context.Orders.CountAsync());
}
}
This runs against the same database engine you use in production, so a broken migration or a mistranslated LINQ query fails in CI instead of on a customer’s checkout page. That’s something a mocked IOrderRepository can’t catch. If you’re keeping repositories mainly for testability, this kind of test is very likely a better return on that investment than another mock.
Decision framework
| Signal | Recommendation |
|---|---|
| Simple CRUD API, one database, small team | Use DbContext directly. SaveChangesAsync is already your Unit of Work. |
| Vertical Slice Architecture | Skip repositories, each slice owns its own query, projected straight from DbContext. |
| DDD with real aggregate boundaries | Custom, aggregate-scoped repository, not a generic one. |
| Clean Architecture, domain layer must not reference EF Core | Repository interface in the domain layer, EF Core implementation in infrastructure. |
| One business operation touches several aggregates and must commit atomically | Explicit IUnitOfWork coordinating multiple repositories. |
| Need to reuse a filtered or paged query across handlers | Specification pattern, not a bigger generic repository. |
| Need confidence your queries are actually correct | Integration tests against a real database (Testcontainers), not mocked repositories. |
| “We might switch ORMs someday” | Rarely a good enough reason on its own. It almost never actually happens, and query logic usually needs rewriting regardless. |
Conclusion
DbContext already is a Unit of Work, and DbSet<T> already is a repository. That part is settled, straight from Microsoft’s own docs and EF Core’s implementation. The actual question in 2026 was never “does EF Core give me these patterns.” It’s “do I need a second, hand-rolled layer on top of them.”
For most greenfield APIs sitting on a single database, the answer is no. Use DbContext directly, project into DTOs with Select, and reach for the Specification pattern when a query needs to be reused elsewhere. Save custom Repository and Unit of Work implementations for when they’re solving something real: DDD aggregate boundaries, a Clean Architecture dependency rule you actually enforce, or coordinating several aggregates in one transaction. And if testability is the reason you’re keeping them, try a real integration test before you reach for another mock. It’ll catch bugs a mock never will.
