If you've ever found yourself sprinkling SaveChanges() calls all over your codebase, or wrestling with half-completed writes when one step fails, you're the audience for this post. We'll walk through the Unit of Work (UoW) pattern in a Clean Architecture solution on .NET, using concrete interfaces and classes from a working project.
TL;DR: Unit of Work coordinates a set of repository operations under a single transaction. It centralizes SaveChanges and transaction boundaries, keeping application logic clean and consistent.
Why Unit of Work?
- Consistency: group related changes, commit everything or nothing.
- Separation of concerns: handlers/services express business intent; UoW handles transaction mechanics.
- Composability: multiple repositories work as one logical operation.
- Testability: fake a single UoW in tests instead of wiring each repository separately.
Where it lives in Clean Architecture
- Domain: contains the repository contracts and the
IUnitOfWorkinterface. - Application: uses those contracts in use cases/handlers (e.g., via MediatR).
- Infrastructure: implements repositories and the Unit of Work, typically on EF Core.
This keeps application logic independent from EF Core specifics while still letting Infrastructure provide efficient implementations.
The Core Contracts (Domain)
At the heart is a generic repository plus specialized repositories, exposed through a single IUnitOfWork that aggregates them and provides transactional helpers.
IUnitOfWorkexposes repos likeUsers,AiProviders,AiModels, and coordinatesSaveChangesAsyncand transactional execution helpers (ExecuteInTransactionAsync).IRepository<T>is your basic CRUD abstraction withGetByIdAsync,AddAsync,Update,Delete,FindAsync,GetQueryable, etc.IUserRepository,IAiProviderRepository,IAiModelRepositoryextendIRepository<T>with domain-specific queries.
These contracts let the Application layer stay persistence-agnostic.
The Infrastructure Implementations (EF Core)
Repository<T>implements the generic methods with EF Core'sDbSet<T>.- Specialized repositories (e.g.,
UserRepository,AiProviderRepository,AiModelRepository) implement domain queries efficiently using LINQ andIncludes. UnitOfWork:- Lazily exposes repository instances (e.g.,
Users,AiProviders,AiModels). - Centralizes
SaveChangesAsync. - Provides
ExecuteInTransactionAsynchelpers that begin a transaction, run your operation, save, and commit/rollback.
- Lazily exposes repository instances (e.g.,
This is the piece that turns many repository calls into one atomic unit.
Registering in DI (Infrastructure β Presentation)
// Program.cs or your composition root
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseNpgsql(builder.Configuration.GetConnectionString("Default")); // or SQL Server, etc.
});
// Repositories & UoW
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IAiProviderRepository, AiProviderRepository>();
builder.Services.AddScoped<IAiModelRepository, AiModelRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
The Application layer will depend only on IUnitOfWork and repository interfaces.
Implementation of ExecuteInTransactionAsync
Your UnitOfWork offers two flavors: one for void-like operations and one returning a result:
public async Task ExecuteInTransactionAsync(
Func<IUnitOfWork, Task> operation,
CancellationToken cancellationToken = default)
{
using var transaction = await BeginTransactionAsync(cancellationToken);
try
{
await operation(this);
await SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
public async Task<T> ExecuteInTransactionAsync<T>(
Func<IUnitOfWork, Task<T>> operation,
CancellationToken cancellationToken = default)
{
using var transaction = await BeginTransactionAsync(cancellationToken);
try
{
var result = await operation(this);
await SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return result;
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
Real-World Handler Example
Here's a MediatR handler that creates an AI provider and its related models in one atomic operation:
public sealed record CreateAiProviderWithModelsCommand(
string Name,
string ApiEndpoint,
List<string> ModelNames) : IRequest<Guid>;
public sealed class CreateAiProviderWithModelsHandler
: IRequestHandler<CreateAiProviderWithModelsCommand, Guid>
{
private readonly IUnitOfWork _uow;
public CreateAiProviderWithModelsHandler(IUnitOfWork uow) => _uow = uow;
public async Task<Guid> Handle(
CreateAiProviderWithModelsCommand request,
CancellationToken ct)
{
return await _uow.ExecuteInTransactionAsync(async uow =>
{
// 1. Create the provider
var provider = new AiProvider(request.Name, request.ApiEndpoint);
await uow.AiProviders.AddAsync(provider, ct);
// 2. Create models for this provider
var models = request.ModelNames.Select(name =>
new AiModel(name, provider.Id)).ToList();
await uow.AiModels.AddRangeAsync(models, ct);
// 3. Return the provider ID
return provider.Id;
// SaveChanges + Commit happens automatically in ExecuteInTransactionAsync
}, ct);
}
}
A Simpler Example with Users
Here's a handler that updates a user's last login and writes an audit entry in the same transaction:
public sealed record MarkUserLoggedInCommand(Guid UserId, DateTime LoggedInAt) : IRequest;
public sealed class MarkUserLoggedInHandler : IRequestHandler<MarkUserLoggedInCommand>
{
private readonly IUnitOfWork _uow;
public MarkUserLoggedInHandler(IUnitOfWork uow) => _uow = uow;
public async Task<Unit> Handle(MarkUserLoggedInCommand request, CancellationToken ct)
{
await _uow.ExecuteInTransactionAsync(async uow =>
{
await uow.Users.UpdateLastLoginAsync(request.UserId, request.LoggedInAt, ct);
// await uow.AuditLogs.AddAsync(new AuditLog { ... }, ct);
}, ct);
return Unit.Value;
}
}
Common Pitfalls & How to Avoid Them
- Calling
SaveChanges()inside repositories
Don't. Let UoW own the save/commit. - Leaking
DbContextto Application layer
Keep EF Core in Infrastructure. Application should reference only the Domain contracts. - Multiple UoWs in a single request
Configure your DI lifetime correctly (Scoped) and resolve one UoW per request. - Long transactions
Keep transactional work small and focused; offload non-critical work (emails, notifications) to outbox/background jobs.
When You Might Skip UoW
- Pure read-only queries (CQRS style) that don't need a transaction.
- Simple apps where EF Core's implicit transaction on
SaveChangesis enough (but you'll often grow out of this quickly).