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 IUnitOfWork interface.
  • 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.

  • IUnitOfWork exposes repos like Users, AiProviders, AiModels, and coordinates SaveChangesAsync and transactional execution helpers (ExecuteInTransactionAsync).
  • IRepository<T> is your basic CRUD abstraction with GetByIdAsync, AddAsync, Update, Delete, FindAsync, GetQueryable, etc.
  • IUserRepository, IAiProviderRepository, IAiModelRepository extend IRepository<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's DbSet<T>.
  • Specialized repositories (e.g., UserRepository, AiProviderRepository, AiModelRepository) implement domain queries efficiently using LINQ and Includes.
  • UnitOfWork:
    • Lazily exposes repository instances (e.g., Users, AiProviders, AiModels).
    • Centralizes SaveChangesAsync.
    • Provides ExecuteInTransactionAsync helpers that begin a transaction, run your operation, save, and commit/rollback.

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

  1. Calling SaveChanges() inside repositories
    Don't. Let UoW own the save/commit.
  2. Leaking DbContext to Application layer
    Keep EF Core in Infrastructure. Application should reference only the Domain contracts.
  3. Multiple UoWs in a single request
    Configure your DI lifetime correctly (Scoped) and resolve one UoW per request.
  4. 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 SaveChanges is enough (but you'll often grow out of this quickly).

References & Further Reading