Lesson 24 of 45 intermediate

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?

Open interactive version (quiz + challenge)

Real-world analogy

Picture a library. A repository is the front desk: you ask for "the book about tides" and the librarian finds it, and you never walk the shelves. A unit of work is the checkout counter: you pile five books on it and the whole stack is stamped in one go, or none is. If a librarian stamped each book as soon as you picked it up, a problem on book four would leave you with three stamped books you did not mean to take. The desk is the repository, and the counter is the unit of work.

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

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. 1. IOrderRepository has two intent-named members: one loads an order with its items, and one adds a new order.
  2. 2. IUnitOfWork has one job, to save everything that changed during the operation.
  3. 3. OrderRepository.GetWithItemsAsync keeps the Include call in one place, so callers do not repeat it.
  4. 4. Add only tracks the new order. It does not save, so the caller decides when the commit happens.
  5. 5. EfUnitOfWork just forwards to db.SaveChangesAsync. When ShopContext and both classes are registered as scoped, they share one DbContext per request.
  6. 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 saved
Need a hint?
What is already in the database when the second call fails?
Show answer
The repository commits inside AddAsync, so the order is saved before the payment step runs. If the payment fails, you are left with an order and no payment, and the caller cannot undo it. Fix: make Add only track the entity, and let the caller (or a unit of work) call SaveChangesAsync once after both steps.

Explain like I'm 5

A repository is a helper who fetches things for you, so you do not search the whole house. A unit of work is a box: you put several changes in it, and then you close it once so everything is saved together.

Fun fact

The EF Core API documentation itself says DbContext is a combination of the Unit Of Work and Repository patterns. The pattern names came first, from Martin Fowler's 2002 book, and the framework took the ideas inside.

Hands-on challenge

Say it out loud in 30 seconds: "EF Core already has DbContext. Do you still add a repository and a unit of work?" Then check: did you mention that DbSet and SaveChanges already play those roles, testability or reused complex queries as reasons, anemic wrappers as the cost, and one commit per operation?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook