Repository and Unit of Work
What they are, why DbContext already is both, and when an extra layer is worth it
Guess first
Pick an answer. You will see if you were right at the end of the lesson.
In EF Core, which statement is accurate?
Real-world analogy
What is it?
A repository is an object that gives your business code a collection-like way to load and store aggregates, hiding query details. A unit of work tracks the changes made during one business operation and commits them in a single transaction. In EF Core, DbContext plays both roles already: DbSet is the repository and SaveChanges is the commit. An extra repository layer is therefore a design decision, and it should pay for itself.
Real-world relevance
Teams split on this. Some wrap every entity in a generic repository out of habit, and end up with thin classes that add nothing and make Include and projection awkward. Others use DbContext directly in handlers and rely on integration tests with a real database. A common middle path is a few specific repositories for aggregates with complex loading, such as an order with items, while simple lookups use DbContext directly. In an interview, show that you know the trade-off.
Key points
- Repository: a door to data — It looks like an in-memory collection: get, add, remove. The caller does not see the SQL or the query details behind it.
- Unit of work: one commit — It collects several changes and saves them together, so they all succeed or all fail. The transaction belongs to the whole operation, not to one step.
- DbContext already is both — Each DbSet works like a repository, and SaveChanges is the unit of work. Wrapping them is a choice with a cost, not a requirement.
- When a wrapper earns its place — It helps when business code must be tested without a database, or when a complex query is reused. It also helps when storage may change. If none applies, it is often noise.
- When it is noise — A repository that forwards every call to DbSet adds a file and a name but no meaning. Those classes are called anemic wrappers.
- Be careful leaking IQueryable — Returning IQueryable lets every caller add its own filters, so query shape escapes the repository. If you want the repository to own the query, return concrete results instead.
- Prefer intent-named methods — A specific repository offers methods such as GetWithItemsAsync. The name says why the data is needed, and the Include details stay in one place.
- Repositories usually do not save — The repository changes tracked objects, and the caller or unit of work saves once at the end. A repository that saves inside each method stops callers from making several steps all-or-nothing.
Quick check
Answer from memory — scrolling up first makes it stick less.
Which situation is the best reason to add a specific repository?
Why can returning IQueryable from a repository be a problem?
Code example
public interface IOrderRepository
{
Task<Order?> GetWithItemsAsync(int id, CancellationToken ct);
void Add(Order order);
}
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct);
}
public class OrderRepository(ShopContext db) : IOrderRepository
{
public Task<Order?> GetWithItemsAsync(int id, CancellationToken ct) =>
db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id, ct);
public void Add(Order order) => db.Orders.Add(order); // tracks only, no save
}
public class EfUnitOfWork(ShopContext db) : IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken ct) => db.SaveChangesAsync(ct);
}
public class CancelOrderService(IOrderRepository orders, IUnitOfWork uow)
{
public async Task CancelAsync(int id, CancellationToken ct)
{
var order = await orders.GetWithItemsAsync(id, ct)
?? throw new KeyNotFoundException($"Order {id} not found");
order.Cancel(); // domain method changes status and items
await uow.SaveChangesAsync(ct); // one commit for the whole operation
}
}Line-by-line walkthrough
- 1. IOrderRepository has two intent-named members: one loads an order with its items, and one adds a new order.
- 2. IUnitOfWork has one job, to save everything that changed during the operation.
- 3. OrderRepository.GetWithItemsAsync keeps the Include call in one place, so callers do not repeat it.
- 4. Add only tracks the new order. It does not save, so the caller decides when the commit happens.
- 5. EfUnitOfWork just forwards to db.SaveChangesAsync. When ShopContext and both classes are registered as scoped, they share one DbContext per request.
- 6. CancelOrderService loads the order, calls order.Cancel(), and commits once at the end. Cancel() is a domain method on the Order entity, not shown here.
Spot the bug
public class OrderRepository(ShopContext db)
{
public async Task AddAsync(Order order)
{
db.Orders.Add(order);
await db.SaveChangesAsync(); // saves right away
}
}
// caller
await orders.AddAsync(order);
await payments.AddAsync(payment); // if this throws, the order is already savedNeed a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- DbContext lifetime, configuration, and initialization (Microsoft Learn)
- Overview of testing applications that use EF Core (Microsoft Learn)
- Designing the infrastructure persistence layer (Microsoft Learn)
Lock it in