# Unit of Work Pattern With EF Core

> EF Core DbContext already implements the Unit of Work pattern. But sometimes you need an explicit abstraction. Here is when and how to implement IUnitOfWork with EF Core.

Published: 2026-08-30. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/unit-of-work-pattern-ef-core

`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](https://milanjovanovic.tech/blog/change-tracker-ef-core) 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](https://milanjovanovic.tech/blog/repository-pattern-csharp) 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](https://milanjovanovic.tech/blog/clean-architecture-dotnet) or projects following DDD, the abstraction pays for itself.

## Defining the Interface

Keep it minimal:

```csharp
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`:

```csharp
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:

```csharp
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:

```csharp
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](https://milanjovanovic.tech/blogs/articles/unit-of-work-pattern-ef-core/unit-of-work-flow.png)

## Repository Pattern With Unit of Work

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

```csharp
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](https://milanjovanovic.tech/blog/change-tracker-ef-core) 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](https://milanjovanovic.tech/blog/working-with-transactions-in-ef-core):

```csharp
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:

```csharp
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:

```csharp
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**](https://milanjovanovic.tech/blog/outbox-pattern-for-reliable-microservices-messaging) 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:

```csharp
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.
