Unit of Work Pattern With EF Core

Unit of Work Pattern With EF Core

6 min read··

dotnetef-coresoftware-architecture

DbContext already tracks a set of changes and commits them through SaveChanges. An additional unit-of-work interface is useful only when it creates a real application boundary or coordinates repositories over that same context. Otherwise it is another name for an abstraction EF Core already provides.

DbContext Is Already a Unit of Work

The Unit of Work pattern groups all the changes made during one business operation and commits them together in a single transaction.

Before implementing anything, let's acknowledge the obvious: DbContext is a Unit of Work. It does exactly that when you call SaveChangesAsync.

The change tracker collects every insert, update, and delete. SaveChangesAsync wraps them all in a transaction. Either everything succeeds or nothing does. That's the Unit of Work pattern.

So why would you create an explicit IUnitOfWork interface?

Why an Explicit Abstraction?

There are two practical reasons:

  1. Dependency inversion - your domain and application layers shouldn't reference DbContext or EF Core directly. An IUnitOfWork interface lets them coordinate persistence without knowing the implementation.

  2. Controlled save points - when multiple repositories modify entities in the same business operation, you want a single SaveChangesAsync call at the end. An explicit Unit of Work makes this coordination visible.

If your application layer already references EF Core and you're fine with that coupling, you might not need a separate abstraction. But in clean architecture or projects following DDD, the abstraction pays for itself.

Defining the Interface

Keep it minimal:

public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

That's the core contract. The application layer calls SaveChangesAsync when the business operation is complete. It doesn't know or care that EF Core is behind it.

Implementing With DbContext

The implementation is straightforward - your DbContext implements IUnitOfWork:

public class AppDbContext : DbContext, IUnitOfWork
{
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options)
    {
    }

    public DbSet<Order> Orders { get; set; }
    public DbSet<Customer> Customers { get; set; }
    public DbSet<Product> Products { get; set; }
}

DbContext already has SaveChangesAsync, so it satisfies the interface without any additional code.

Register both:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(connectionString));

builder.Services.AddScoped<IUnitOfWork>(sp =>
    sp.GetRequiredService<AppDbContext>());

Both AppDbContext and IUnitOfWork resolve to the same instance within a scope. This is critical - repositories and the Unit of Work must share the same DbContext.

Coordinating Repositories

Here's the pattern in a use case. Multiple repositories make changes, and a single SaveChangesAsync commits everything:

public class PlaceOrderCommandHandler
{
    private readonly IOrderRepository _orderRepository;
    private readonly ICustomerRepository _customerRepository;
    private readonly IUnitOfWork _unitOfWork;

    public PlaceOrderCommandHandler(
        IOrderRepository orderRepository,
        ICustomerRepository customerRepository,
        IUnitOfWork unitOfWork)
    {
        _orderRepository = orderRepository;
        _customerRepository = customerRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task Handle(PlaceOrderCommand command, CancellationToken ct)
    {
        var customer = await _customerRepository.GetByIdAsync(command.CustomerId, ct);

        var order = Order.Create(customer.Id, command.Items);

        customer.IncrementOrderCount();

        _orderRepository.Add(order);

        await _unitOfWork.SaveChangesAsync(ct);
    }
}

Both the Order insert and the Customer update happen in one transaction. If either fails, both are rolled back.

An order repository and a customer repository both stage changes that the Unit of Work commits with a single SaveChangesAsync call inside one transaction

Repository Pattern With Unit of Work

The repositories handle querying and adding entities. They don't call SaveChangesAsync:

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
    void Add(Order order);
    void Remove(Order order);
}

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct)
    {
        return await _context.Orders
            .Include(o => o.LineItems)
            .FirstOrDefaultAsync(o => o.Id == id, ct);
    }

    public void Add(Order order)
    {
        _context.Orders.Add(order);
    }

    public void Remove(Order order)
    {
        _context.Orders.Remove(order);
    }
}

Notice that Add and Remove are synchronous. They only tell the change tracker about the entity. The actual database operation happens in SaveChangesAsync.

Explicit Transactions

Sometimes SaveChangesAsync isn't enough. You need multiple save points or you're coordinating with external systems. Use explicit transactions:

public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
    Task BeginTransactionAsync(CancellationToken cancellationToken = default);
    Task CommitTransactionAsync(CancellationToken cancellationToken = default);
    Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
}

Implementation:

public class AppDbContext : DbContext, IUnitOfWork
{
    private IDbContextTransaction? _transaction;

    public async Task BeginTransactionAsync(CancellationToken ct)
    {
        _transaction = await Database.BeginTransactionAsync(ct);
    }

    public async Task CommitTransactionAsync(CancellationToken ct)
    {
        if (_transaction is null) return;

        await _transaction.CommitAsync(ct);
        await _transaction.DisposeAsync();
        _transaction = null;
    }

    public async Task RollbackTransactionAsync(CancellationToken ct)
    {
        if (_transaction is null) return;

        await _transaction.RollbackAsync(ct);
        await _transaction.DisposeAsync();
        _transaction = null;
    }
}

Use it when a business operation has multiple steps that each need to be persisted:

await _unitOfWork.BeginTransactionAsync(ct);

try
{
    _orderRepository.Add(order);
    await _unitOfWork.SaveChangesAsync(ct);

    await _paymentService.ChargeAsync(order.TotalAmount, ct);

    order.MarkAsPaid();
    await _unitOfWork.SaveChangesAsync(ct);

    await _unitOfWork.CommitTransactionAsync(ct);
}
catch
{
    await _unitOfWork.RollbackTransactionAsync(ct);
    throw;
}

A word of caution about that example: holding a database transaction open across an external HTTP call (the payment charge) means a slow payment provider keeps your database connection and locks tied up. It's acceptable for low-traffic flows, but at scale you want the Outbox pattern instead: commit locally, then integrate asynchronously.

One more gotcha: if you enabled a retrying execution strategy (EnableRetryOnFailure), you can't just call BeginTransactionAsync. Wrap the whole operation in CreateExecutionStrategy().ExecuteAsync(...), or EF Core throws because a retry can't replay a manually started transaction.

Domain Events and Unit of Work

If you're using domain events, dispatch them after SaveChangesAsync succeeds. This ensures events aren't published for changes that were rolled back:

public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
    var domainEvents = ChangeTracker.Entries<Entity>()
        .SelectMany(e => e.Entity.PopDomainEvents())
        .ToList();

    var result = await base.SaveChangesAsync(ct);

    foreach (var domainEvent in domainEvents)
    {
        await _publisher.Publish(domainEvent, ct);
    }

    return result;
}

The _publisher field is whatever event dispatcher you inject into the context (MediatR's IPublisher, for example).

Note the tradeoff: publishing after the save means a crash between the save and the publish loses the events. If the events must not be lost, persist them in the same transaction using the transactional outbox instead of publishing in memory.

When You Don't Need IUnitOfWork

Not every project needs this abstraction. Skip it when:

  • Your application layer already depends on EF Core
  • You have simple CRUD operations with a single repository per use case
  • You're building a small API without layered architecture

In these cases, inject AppDbContext directly and call SaveChangesAsync in your handler. Adding IUnitOfWork would be ceremony without value.

Summary

DbContext already tracks changes and commits them as a unit. Add IUnitOfWork only when the application needs a narrow commit boundary, and ensure every participating repository shares the same scoped context. Use an explicit transaction for multiple saves that must roll back together, not as ceremony around a single SaveChanges call.

Frequently Asked Questions

Is DbContext a Unit of Work?

Yes. DbContext tracks every insert, update, and delete during a business operation and commits them in a single transaction when you call SaveChangesAsync. That is the Unit of Work pattern.

Do I need the Unit of Work pattern with EF Core?

Only if you want to keep EF Core out of your application layer, typically in Clean Architecture or DDD projects. If your handlers already use DbContext directly, an extra IUnitOfWork interface adds ceremony without value.

Should repositories call SaveChanges?

No. Repositories should only add, remove, and query entities. A single SaveChangesAsync call at the end of the use case commits all changes across repositories in one transaction.

How do I share one DbContext between repositories and the Unit of Work?

Register the DbContext as scoped (the default with AddDbContext) and register IUnitOfWork with a factory that resolves the same DbContext instance. Everything in one HTTP request scope then shares one change tracker and one transaction.

When should I use an explicit transaction instead of SaveChangesAsync?

When a business operation needs multiple save points, for example saving an order before calling a payment service and then updating the order status. SaveChangesAsync alone covers the common case of committing all tracked changes at once.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.