Skip to main content
SEMastery

Implementing the Unit of Work Pattern in EF Core

Learn the Unit of Work pattern in EF Core with simple C# examples, diagrams, transactions, and when DbContext alone is already enough for you.

14 min readUpdated December 4, 2025

A quick story before the code

Imagine your mother goes shopping in a busy market. She visits the vegetable seller, the rice shop, and the spice corner. She does not pay each shop one by one while walking around. Instead, she puts everything into one basket. At the end, she goes to the counter and pays for the whole basket in a single bill.

Now think about what happens if the counter machine fails. Nothing is lost. She did not pay for the vegetables yet, so she still has her money and her vegetables. Either the whole basket gets paid for, or none of it does. There is no half-paid mess.

That basket is the Unit of Work. The shops are your repositories. The final payment at the counter is SaveChanges. This post shows how that idea works inside Entity Framework Core, and just as importantly, when you do not need it.

The shopping basket as a Unit of Work

What is a Unit of Work?

A Unit of Work keeps a list of all the changes you want to make to the database. It collects inserts, updates, and deletes. Then, at the right moment, it sends them all to the database together as one single operation.

The key word is together. If one change cannot be saved, none of them are saved. Your data never ends up half done. This "all or nothing" behaviour is called being atomic.

Here is the important part for EF Core students. The DbContext is already a Unit of Work. EF Core was built with this pattern inside it. When you load entities, change them, and call SaveChanges, EF Core has been quietly keeping a basket for you the whole time.

So why do people write a separate IUnitOfWork class? Mostly for two reasons:

  1. To hide DbContext behind their own interface, so the rest of the app does not depend on EF Core directly.
  2. To give many repositories one shared place to save, so they all commit together.

We will cover both, plus the honest truth about when to skip it.

The change tracker: the basket inside EF Core

Before writing any pattern, you must understand the change tracker. This is the part of EF Core that remembers what you did to each entity.

Every entity that EF Core knows about has a state. Think of it as a little label stuck on each item in the basket.

Entity stateWhat it meansWhat SaveChanges does
AddedYou created a new entityRuns an INSERT
ModifiedYou changed a loaded entityRuns an UPDATE
DeletedYou marked it for removalRuns a DELETE
UnchangedLoaded but not touchedNothing
DetachedEF Core is not tracking itNothing

When you call SaveChanges, EF Core walks through every tracked entity, reads its label, and turns it into SQL. All of that SQL runs inside one database transaction.

How the change tracker turns labels into SQL

A first example with plain DbContext

Let us build a tiny shop. A customer places an order, and we also clear their cart. These two things must happen together. If we save the order but fail to clear the cart, the customer is confused and may order twice.

Here is the model and the context.

public class Order
{
    public int Id { get; set; }
    public string CustomerName { get; set; } = "";
    public decimal Total { get; set; }
}
 
public class CartItem
{
    public int Id { get; set; }
    public string CustomerName { get; set; } = "";
    public string Product { get; set; } = "";
}
 
public class ShopDbContext : DbContext
{
    public ShopDbContext(DbContextOptions<ShopDbContext> options)
        : base(options) { }
 
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<CartItem> CartItems => Set<CartItem>();
}

Now the service that does both jobs at once. Notice that there is no custom Unit of Work class here. The DbContext is the Unit of Work.

public class CheckoutService
{
    private readonly ShopDbContext _db;
 
    public CheckoutService(ShopDbContext db) => _db = db;
 
    public async Task PlaceOrderAsync(string customer, decimal total)
    {
        // Job 1: create the order (Added)
        _db.Orders.Add(new Order { CustomerName = customer, Total = total });
 
        // Job 2: clear the cart (Deleted)
        var items = await _db.CartItems
            .Where(c => c.CustomerName == customer)
            .ToListAsync();
        _db.CartItems.RemoveRange(items);
 
        // One basket, one payment. Atomic.
        await _db.SaveChangesAsync();
    }
}

That one SaveChangesAsync call wraps both the insert and the deletes in a single transaction. If the database rejects any part, the whole thing rolls back. You already have Unit of Work behaviour for free.

This is why senior developers often say: for many apps, you do not need to build anything extra. Keep this in your mind as we go further.

So why add an explicit Unit of Work?

If DbContext already does the job, when does a custom Unit of Work earn its place? Mainly when you also use the Repository pattern and you have several repositories.

Imagine you have an OrderRepository, a CartRepository, and a PaymentRepository. Each one only knows about its own kind of data. But they must all save together. If each repository called its own SaveChanges, you would get three separate payments at the counter. One could fail after another already went through. That breaks the "all or nothing" rule.

The Unit of Work fixes this. It holds the one shared DbContext. The repositories use that same context. Only the Unit of Work is allowed to call SaveChanges. So no matter how many repositories touched the basket, there is exactly one payment.

Many repositories, one save

Repositories
Shared Context
Unit of Work
One Commit

Steps

1

Repositories

Each handles one entity type

2

Shared Context

All point to the same DbContext

3

Unit of Work

Owns the context and SaveChanges

4

One Commit

All changes save together

Repositories share a context; only the Unit of Work commits.

Building the Unit of Work interface

Let us write a clean, small interface. Notice it is tiny. A Unit of Work does not need many methods. Its main job is to give access to repositories and to save.

public interface IUnitOfWork : IAsyncDisposable
{
    IOrderRepository Orders { get; }
    ICartRepository Carts { get; }
 
    Task<int> SaveChangesAsync(CancellationToken ct = default);
}

Two repository properties and one save method. Clean and easy to read. A new student can understand this in seconds.

Here is a simple repository interface and class. The repository never calls SaveChanges. That is the golden rule. It only adds, removes, and queries.

public interface IOrderRepository
{
    Task AddAsync(Order order, CancellationToken ct = default);
    Task<Order?> GetAsync(int id, CancellationToken ct = default);
}
 
public class OrderRepository : IOrderRepository
{
    private readonly ShopDbContext _db;
 
    public OrderRepository(ShopDbContext db) => _db = db;
 
    public async Task AddAsync(Order order, CancellationToken ct = default)
        => await _db.Orders.AddAsync(order, ct);
 
    public async Task<Order?> GetAsync(int id, CancellationToken ct = default)
        => await _db.Orders.FindAsync(new object[] { id }, ct);
}

Building the Unit of Work class

Now the Unit of Work itself. It creates the repositories using the same context instance. That shared context is the whole point.

public class UnitOfWork : IUnitOfWork
{
    private readonly ShopDbContext _db;
 
    public UnitOfWork(ShopDbContext db)
    {
        _db = db;
        Orders = new OrderRepository(_db);
        Carts = new CartRepository(_db);
    }
 
    public IOrderRepository Orders { get; }
    public ICartRepository Carts { get; }
 
    public Task<int> SaveChangesAsync(CancellationToken ct = default)
        => _db.SaveChangesAsync(ct);
 
    public ValueTask DisposeAsync() => _db.DisposeAsync();
}

And the checkout service now reads almost like plain English. It uses repositories to change things, then asks the Unit of Work to save once.

public class CheckoutService
{
    private readonly IUnitOfWork _uow;
 
    public CheckoutService(IUnitOfWork uow) => _uow = uow;
 
    public async Task PlaceOrderAsync(string customer, decimal total)
    {
        await _uow.Orders.AddAsync(new Order
        {
            CustomerName = customer,
            Total = total
        });
 
        _uow.Carts.ClearFor(customer);
 
        // Only the Unit of Work saves. One transaction.
        await _uow.SaveChangesAsync();
    }
}
The flow of one checkout through the Unit of Work

Registering everything in ASP.NET Core

To use this in a web app, register the context and the Unit of Work in the service container. The lifetime matters. Use scoped so each web request gets its own fresh Unit of Work and its own DbContext.

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddDbContext<ShopDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
 
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<CheckoutService>();
 
var app = builder.Build();

Why scoped and not singleton? A DbContext is not safe to share across many requests at the same time. It holds the change tracker, which is like a private basket. Two requests must never share one basket, or their items get mixed. Scoped gives each request its own.

Lifetime of a request

Request in
Scope created
Work done
Scope disposed

Steps

1

Request in

User calls an endpoint

2

Scope created

New DbContext and UnitOfWork

3

Work done

Repositories change data, save once

4

Scope disposed

Context cleaned up at request end

Each HTTP request gets a fresh, isolated Unit of Work.

When you need more than one SaveChanges

Sometimes one call to SaveChanges is not enough. A common case is when you save an order first to get its generated database Id, and then use that Id for a second save. Now you have two SaveChanges calls. By default they are two separate transactions. If the second one fails, the first is already committed. That breaks atomicity.

For this, EF Core lets you open an explicit transaction. You can add a couple of methods to your Unit of Work to support it.

public async Task ExecuteInTransactionAsync(
    Func<Task> work,
    CancellationToken ct = default)
{
    await using var tx = await _db.Database.BeginTransactionAsync(ct);
    try
    {
        await work();           // may call SaveChanges many times
        await tx.CommitAsync(ct);
    }
    catch
    {
        await tx.RollbackAsync(ct);
        throw;
    }
}

Now every SaveChanges inside work shares the same outer transaction. If anything throws, the whole block rolls back. The basket stays whole.

One caution. If you use SQL Server with retrying execution strategies turned on, you must wrap the transaction using Database.CreateExecutionStrategy() instead. Otherwise EF Core will throw, because it cannot safely retry a hand made transaction. Read the official EF Core transaction docs linked at the end for the exact code.

The big honest question: should you even do this?

Here is the part many tutorials skip. Adding a Unit of Work and repositories on top of EF Core is not free. You write more code. You add more interfaces. You hide useful EF Core features behind your own walls.

Microsoft's own EF Core team and many respected community voices point out that DbContext already is the Unit of Work, and DbSet<T> already is a repository. Wrapping them again can be wasted effort, especially in small projects.

So use this simple table to decide.

SituationDo you need a custom Unit of Work?
Small CRUD app, one team, EF Core onlyNo. Just use DbContext directly
You want easy mocking in testsMaybe. But EF Core in-memory or SQLite works too
Many repositories that must save togetherOften yes, it keeps saving in one place
You must hide EF Core from the domain layerYes, the interface gives you that wall
You plan to swap EF Core for something elseRarely true in practice. Be honest with yourself

The pattern is a tool, not a rule. A good carpenter does not use a hammer on every job. Pick it when it solves a real problem you actually have.

A simple decision path

Common mistakes to avoid

A few traps catch new developers again and again. Learn them now and save future pain.

  • Calling SaveChanges inside a repository. This breaks the whole point. Now each repository commits on its own and you lose atomicity. Only the Unit of Work saves.
  • Making the DbContext a singleton. This causes crashes and mixed up data when many requests run together. Use scoped.
  • Catching every exception silently. If a save fails, let it bubble up so the transaction rolls back. Swallowing errors hides bugs.
  • Adding the pattern for a project that does not need it. More code is more bugs. Start simple. Add structure only when the pain is real.
  • Returning IQueryable from repositories and then forgetting it runs later. A query that has not run yet can surprise you. Decide where the database is actually hit.

Testing the Unit of Work

A nice side effect of the interface is easy testing. Because your service depends on IUnitOfWork, you can swap in a fake or use an in-memory database. Here is the in-memory approach, which is closest to the real thing.

[Fact]
public async Task PlaceOrder_SavesOrder()
{
    var options = new DbContextOptionsBuilder<ShopDbContext>()
        .UseInMemoryDatabase("test-db")
        .Options;
 
    await using var db = new ShopDbContext(options);
    var uow = new UnitOfWork(db);
    var service = new CheckoutService(uow);
 
    await service.PlaceOrderAsync("Asha", 250m);
 
    Assert.Equal(1, await db.Orders.CountAsync());
}

This test proves the order was saved without touching a real database. Fast, clean, and safe to run thousands of times.

A note on modern .NET

This pattern has not changed in spirit for years, and it still works exactly the same on .NET 10, the current LTS release, with C# 14. The async methods, the change tracker, and transactions all behave as shown. You do not need any special library. Plain EF Core is enough.

If you have seen tutorials that pull in extra messaging libraries for related patterns, note that some popular ones such as MediatR and MassTransit moved to a commercial license. The plain Unit of Work shown here needs none of them. It is just EF Core and your own small classes.

Quick recap

  • A Unit of Work groups many database changes into one atomic save. All succeed or none do, like paying for a full shopping basket at once.
  • EF Core's DbContext is already a Unit of Work, and DbSet<T> is already a repository. For many apps that is all you need.
  • The change tracker labels each entity as Added, Modified, or Deleted, then SaveChanges turns those labels into one transaction.
  • A custom Unit of Work mainly helps when several repositories must share one context and save together, or when you want to hide EF Core behind an interface.
  • The golden rule: only the Unit of Work calls SaveChanges. Repositories never do.
  • Register the context and Unit of Work as scoped so each request gets its own isolated basket.
  • For two or more saves that must stay atomic, open an explicit transaction with BeginTransactionAsync.
  • Do not add the pattern just because. Use the decision table. Start simple and add structure only when a real problem appears.

References and further reading

Related Patterns