Most business operations modify more than one piece of data. Placing an order might create an order, decrease product stock and write an audit record. Those changes should either all succeed or all fail.
If each step commits independently, a failure in the middle leaves the database inconsistent.
We need one boundary that groups those changes and commits them together.
That's where the Unit of Work pattern comes in.
Unit of Work
The Unit of Work pattern defines a transaction boundary for a business operation. It coordinates related changes and commits them as a single operation.
Without this pattern, every change could immediately trigger a database write. That leads to more round trips, weaker consistency and harder error handling.
In practice, all related work shares one connection and transaction. You commit once when the business operation is complete.
Unit of Work with EF Core
If you use EF Core, you already have a Unit of Work implementation.
DbContext combines two responsibilities. It tracks entity changes through its Change Tracker and acts as the Unit of Work by persisting those changes with SaveChangesAsync. EF Core documentation describes the context as implementing the Unit of Work pattern and in ASP.NET Core it is typically registered as a scoped service, one instance per request.
The DbSet<T> properties act as repositories. You can query and modify entities throughout your business operation without committing each change immediately:
public async Task PlaceOrderAsync(
PlaceOrderRequest request,
ApplicationDbContext dbContext)
{
var product = await dbContext.Products
.FirstAsync(p => p.Id == request.ProductId);
product.ReduceStock(request.Quantity);
dbContext.Orders.Add(new Order(
request.ProductId,
request.Quantity));
dbContext.AuditLogs.Add(new AuditLog("OrderPlaced"));
await dbContext.SaveChangesAsync();
}
A common misconception is that the Unit of Work pattern is a class that aggregates multiple generic repositories and passes the same DbContext instance into their constructors. That does not implement Unit of Work. It simply wraps or exposes the Unit of Work that EF Core already provides.
You will find this approach in many C# examples and tutorials, including some Microsoft samples. The usual argument is that repositories share the same context, which is true and useful. However, sharing a context is not what defines the Unit of Work pattern. The important part is having a boundary where related changes are coordinated and committed together as one atomic operation.
By adding an IUnitOfWork interface with properties like Products and Orders, you are usually just creating another abstraction over EF Core. You are adapting the existing Unit of Work rather than implementing a new one.
For more on how EF Core commits work, see my blog post on Transactions in EF Core.
Custom Unit of Work with Dapper
A custom Unit of Work becomes useful when your stack does not provide one out of the box.
Dapper is a good example. It maps queries to objects, but it does not track changes or manage a unit of work for you. You own connections, SQL and transactions. I've covered the basics in my Dapper blog post.
Unlike EF Core, Dapper executes SQL immediately. It does not track modified entities or decide what should be persisted. A Unit of Work therefore focuses on coordinating the connection and transaction while repositories execute SQL explicitly.
Here is a simple abstraction that shares one connection and one transaction across repositories:
public interface IUnitOfWork : IAsyncDisposable
{
IDbConnection Connection { get; }
IDbTransaction? Transaction { get; }
Task BeginAsync();
Task CommitAsync();
Task RollbackAsync();
}
And the implementation with Npgsql for PostgreSQL:
public sealed class UnitOfWork(string connectionString) : IUnitOfWork
{
private readonly DbConnection _connection = new NpgsqlConnection(connectionString);
private DbTransaction? _transaction;
public IDbConnection Connection => _connection;
public IDbTransaction? Transaction => _transaction;
public async Task BeginAsync()
{
if (_transaction is not null)
throw new InvalidOperationException("Transaction already started.");
if (_connection.State != ConnectionState.Open)
await _connection.OpenAsync();
_transaction = await _connection.BeginTransactionAsync();
}
public async Task CommitAsync()
{
if (_transaction is null)
throw new InvalidOperationException("Transaction was not started.");
await _transaction.CommitAsync();
await _transaction.DisposeAsync();
_transaction = null;
}
public async Task RollbackAsync()
{
if (_transaction is null)
return;
await _transaction.RollbackAsync();
await _transaction.DisposeAsync();
_transaction = null;
}
public async ValueTask DisposeAsync()
{
if (_transaction is not null)
await _transaction.DisposeAsync();
await _connection.DisposeAsync();
}
}
This implementation more closely follows Martin Fowler's original description of the Unit of Work pattern. You own the connection and transaction. Repositories do not commit on their own. They run SQL against the shared boundary:
public sealed class ProductRepository(IUnitOfWork unitOfWork) : IProductRepository
{
public Task ReduceStockAsync(Guid productId, int quantity)
=> unitOfWork.Connection.ExecuteAsync(
"""
UPDATE "Products"
SET "Stock" = "Stock" - @Quantity
WHERE "Id" = @ProductId
""",
new { ProductId = productId, Quantity = quantity },
unitOfWork.Transaction);
}
public sealed class OrderRepository(IUnitOfWork unitOfWork) : IOrderRepository
{
public Task AddAsync(Order order)
=> unitOfWork.Connection.ExecuteAsync(
"""
INSERT INTO "Orders" ("Id", "ProductId", "Quantity")
VALUES (@Id, @ProductId, @Quantity)
""",
order,
unitOfWork.Transaction);
}
The handler starts the transaction, runs the work and commits once:
public sealed class PlaceOrderHandler(
IUnitOfWork unitOfWork,
IProductRepository products,
IOrderRepository orders)
{
public async Task HandleAsync(PlaceOrderRequest request)
{
await unitOfWork.BeginAsync();
try
{
await products.ReduceStockAsync(request.ProductId, request.Quantity);
await orders.AddAsync(new Order(request.ProductId, request.Quantity));
await unitOfWork.CommitAsync();
}
catch
{
await unitOfWork.RollbackAsync();
throw;
}
}
}
Notice that the repositories never call CommitAsync. Their only responsibility is executing SQL. The Unit of Work owns the transaction boundary.
Register everything as scoped so one request shares one Unit of Work instance:
builder.Services.AddScoped<IUnitOfWork>(_ =>
new UnitOfWork(connectionString));
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
Because it is scoped, every repository in that request gets the same connection and transaction.
NOTE: Every repository must use unitOfWork.Connection and unitOfWork.Transaction. Separate connections break the shared transaction.
Conclusion
Unit of Work coordinates related changes for one business transaction, it is not a repository aggregator around DbContext.
With EF Core, you already get that behavior through DbContext and DbSet<T>. A custom wrapper is rarely needed.
When you own the connection and transaction, a custom Unit of Work pays off.
You could extend this implementation with change tracking, dirty checking and deferred writes. At that point, however, you're rebuilding features that EF Core already provides out of the box.
If you want to check out examples I created, you can find the source code here:
Source CodeI hope you enjoyed it, subscribe and get a notification when a new blog is up!
