Select a result to preview
The Repository pattern provides a collection-like interface for accessing domain objects, hiding the persistence mechanism from the domain layer. The Unit of Work pattern tracks all changes made during a business operation and commits them as a single atomic transaction. Together they decouple domain logic from data access technology and make persistence testable.
In EF Core, DbContext already implements both patterns: it acts as a repository-like gateway (you query through DbSet<T>) and as a Unit of Work (change tracking + SaveChangesAsync()). Wrapping EF Core in additional Repository/UoW abstractions is optional — justified when you need to swap persistence technology or enforce strict domain boundaries.
A Repository exposes domain-oriented methods (FindById, FindByCustomer, Save) rather than raw SQL or LINQ. The domain layer depends on the interface; the infrastructure layer provides the implementation.
// Domain layer: depends on abstraction
public interface IOrderRepository
{
Task<Order?> FindAsync(OrderId id, CancellationToken ct);
Task<IReadOnlyList<Order>> FindByCustomerAsync(CustomerId customerId, CancellationToken ct);
void Add(Order order);
void Remove(Order order);
}
// Infrastructure layer: EF Core implementation
public sealed class EfOrderRepository(AppDbContext db) : IOrderRepository
{
public Task<Order?> FindAsync(OrderId id, CancellationToken ct) =>
db.Orders
.Include(o => o.LineItems)
.FirstOrDefaultAsync(o => o.Id == id, ct);
public Task<IReadOnlyList<Order>> FindByCustomerAsync(CustomerId customerId, CancellationToken ct) =>
db.Orders
.Where(o => o.CustomerId == customerId)
.ToListAsync(ct)
.ContinueWith(t => (IReadOnlyList<Order>)t.Result, ct);
public void Add(Order order) => db.Orders.Add(order);
public void Remove(Order order) => db.Orders.Remove(order);
}
Note: Add and Remove don't call SaveChanges — that's the Unit of Work's responsibility.
The Unit of Work tracks all changes within a business operation and commits them atomically. In EF Core, DbContext is the Unit of Work:
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct);
}
// AppDbContext implements both IUnitOfWork and exposes repositories
public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options), IUnitOfWork
{
public DbSet<Order> Orders => Set<Order>();
}
// Application service: uses repository + unit of work
public sealed class PlaceOrderHandler(IOrderRepository orders, IUnitOfWork uow)
{
public async Task HandleAsync(PlaceOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd.CustomerId, cmd.LineItems);
orders.Add(order);
await uow.SaveChangesAsync(ct); // single transaction for all changes
}
}
EF Core's DbContext already gives you Repository + UoW behavior. Adding explicit interfaces is justified when:
using Microsoft.EntityFrameworkCore in domain projects).When NOT to add the abstraction: if you're building a simple CRUD service and the only consumer is EF Core, the extra interfaces add indirection without benefit. Inject DbContext directly.
IQueryable<T>What goes wrong: the repository leaks EF Core's IQueryable<T> to the application layer. Callers add .Where() and .Include() outside the repository, coupling the application layer to EF Core.
Why it happens: returning IQueryable<T> feels flexible — callers can filter however they want.
Mitigation: return IReadOnlyList<T> or IEnumerable<T>. Add specific query methods to the repository interface (FindByCustomer, FindPendingOlderThan) rather than exposing raw queryable.
What goes wrong: a single IRepository<T> with GetById, GetAll, Add, Update, Delete is used for every entity. It forces every aggregate to expose the same interface, including operations that don't make sense for that aggregate.
Why it happens: it looks like a clean abstraction and reduces boilerplate.
Mitigation: use aggregate-specific repositories (IOrderRepository, ICustomerRepository) with methods that reflect the domain's actual access patterns. Generic repositories are fine as a base implementation, but the interface should be domain-specific.
| Approach | Strengths | Weaknesses | When to use |
|---|---|---|---|
Direct DbContext |
Simple, no extra abstraction, full EF Core power | Couples application layer to EF Core, harder to unit-test | Simple CRUD, small teams, no domain isolation requirement |
| Repository + UoW interfaces | Testable, domain-isolated, swappable persistence | Extra boilerplate, risk of leaky abstractions | DDD projects, strict layering, multiple persistence backends |
Decision rule: start with direct DbContext injection. Add Repository/UoW interfaces when you need to unit-test application services without a real database, or when the domain layer must not reference EF Core. Don't add the abstraction speculatively.
DbContext tracks all changes to loaded entities in its change tracker.SaveChangesAsync() wraps all pending inserts, updates, and deletes in a single database transaction.DbContext instance and commit atomically — exactly what Unit of Work provides.DbContext instance (Scoped lifetime in ASP.NET Core DI). If you accidentally register DbContext as Singleton or Transient, the Unit of Work semantics break.GetAll() on an Order aggregate with millions of rows).IQueryable<T>, coupling callers to EF Core.