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:
-
Dependency inversion - your domain and application layers shouldn't reference
DbContextor EF Core directly. AnIUnitOfWorkinterface lets them coordinate persistence without knowing the implementation. -
Controlled save points - when multiple repositories modify entities in the same business operation, you want a single
SaveChangesAsynccall 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.
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.



