Updated on

The unit of work pattern groups every change a business operation makes and saves them together, in one transaction, so they all succeed or all fail.

We will discover its advantages and disadvantages, and build an example implementation with Entity Framework Core. Moreover, we will look at the unit of work’s connection with the repository pattern, and we will see what happens to a bank transfer that fails halfway.

To download the source code for this article, you can visit our GitHub repository.

What Is the Unit of Work Pattern?

A unit of work is a logical unit of operations that must complete or fail together. This is where the pattern’s name comes from.

In databases, we execute every operation inside transactions, which makes operations an indivisible unit. However, in a real-world scenario, we almost always want to group multiple operations into one.

Imagine making a transfer from a bank account to another account. We deduct the funds from the sender’s account, add funds to the recipient’s account, and record the transfer. Treating these as separate transactions could deduct the money from the sender and never credit the recipient.

The unit of work pattern keeps track of everything the operation changes, and then saves all of it in a single transaction. In C#, it usually shows up as an IUnitOfWork interface with one SaveChangesAsync() method. With EF Core, the implementation is a thin wrapper around DbContext, which tracks the changes for us.

To understand what the unit of work pattern is about, first, we should take a look at the repository pattern. In a nutshell, repositories are the data access objects and their job is to handle the communication with the database. With the unit of work pattern, we split this communication responsibility.

Without the unit of work pattern, the repositories have to manage the transaction’s lifetime. Usually, this means one transaction per operation. So we must introduce an abstraction above repositories that decides when to save.

Martin Fowler’s catalog entry for the pattern starts with “Maintains a list of objects affected by a business transaction”. It goes on to say that the unit of work also coordinates writing out the changes.

But first, let’s see the advantages of using it from a technical point of view.

Why Should We Use the Unit of Work Pattern?

When speaking about the pros of the unit of work pattern it’s hard to distinguish it from the repository pattern’s, so let’s discuss the advantages of using both.

Data Consistency

Because the save is extracted from the repositories, it’s really convenient to group operations together and commit or roll them back as a single unit. So our data will never get stuck in a half-finished state.

To see why this matters, let’s look at the code the pattern replaces. Each repository has its own SaveChangesAsync(), and the transfer handler saves after each step. It works most of the time, but don’t copy it:

var withdrawal = from.Withdraw(command.Amount);
if (!withdrawal.IsSuccess)
    return withdrawal.Error!;

to.Deposit(command.Amount);
await accounts.SaveChangesAsync(cancellationToken);

var transfer = Transfer.Create(from, to, command.Amount, command.Reference);
transfers.Add(transfer);
await transfers.SaveChangesAsync(cancellationToken);

The first save commits both new balances. Then the handler adds the transfer record and commits again, in a second transaction.

So what happens when the second save fails? Say a client sends the same transfer twice, after a double-click or a retry. Every transfer carries a reference, and the database refuses to store two transfers with the same one.

We ran this handler against the SQLite database we build below, with Alice at 100.00, Bob at 50.00 and 30.00 per transfer. After the retry, Alice had 40.00, Bob had 110.00, and the database held one transfer. Alice paid twice for one recorded transfer, and nothing in our code noticed.

With a unit of work, the handler makes all the changes in memory and commits them once, at the end. Here’s the same retry both ways:

Two panels compare the same retried transfer. On the left, each repository saves on its own: the first save updates Alice to 40.00 and Bob to 110.00 and commits, then the second save's insert of transfer TR-1001 fails on the duplicate reference, so Alice ends at 40.00, Bob at 110.00, with one transfer recorded. On the right, one unit of work sends both updates and the insert in one transaction, the insert fails, the transaction ends without a commit, and the balances stay at 70.00 and 80.00.

Abstraction

Abstraction can be considered a benefit because it helps us in many ways. Firstly, it makes our code loosely coupled by abstracting the details of data access, so our use cases don’t have to know about EF Core.

Because of this, many things become easier. Mocking the database when writing unit tests is just a few lines of code for any use case that we implement, as we’ll see later.

It’s often sold as a way to switch to another database or ORM, too. Few teams ever do that, so we wouldn’t adopt the pattern for it.

Does EF Core Already Implement the Unit of Work Pattern?

Yes. The DbContext class already implements the unit of work pattern. Microsoft’s DbContext reference says so in one sentence: “DbContext is a combination of the Unit Of Work and Repository patterns.”

Moreover, it does it without opening a transaction prematurely, by using an in-memory change tracker.

The job of this change tracker is to keep track of entities that are added, modified, or deleted, then generate and execute the appropriate SQL only when we call the SaveChangesAsync() method. By default, all of that SQL runs in one transaction.

So why would we write an IUnitOfWork at all? First, the use case shouldn’t know about EF Core, and a handler that takes a DbContext does.

Second, the save doesn’t belong to any one repository. In the handler above, both repositories share one DbContext, so accounts.SaveChangesAsync() would also save a transfer added through transfers. We checked, and it did. The save belongs to the whole operation, so it gets its own interface, and that interface is IUnitOfWork.

How to Implement the Unit of Work Pattern?

We’ll build a console app with two bank accounts, Alice with 100.00 and Bob with 50.00, and one use case: moving money between them. The code lives in three folders, Domain, Application and Infrastructure, one per layer. In a bigger solution, each folder would be its own project.

With the .NET 10 SDK installed, let’s create a solution with one console project, and add EF Core with its SQLite provider:

dotnet new sln -n Banking
dotnet new console -n Banking
dotnet solution add Banking
dotnet package add Microsoft.EntityFrameworkCore.Sqlite --version 10.0.12 --project Banking
dotnet package add Microsoft.Extensions.DependencyInjection --version 10.0.12 --project Banking

A console app has no dependency injection container of its own, so we also add the one ASP.NET Core uses. Both versions are pinned, so these commands keep working once .NET 11 is out.

An SDK from before .NET 10 only knows the dotnet sln add and dotnet add package spellings. You’ll also get Banking.slnx, because .NET 10 writes solution files in the XML format now.

The Domain Entities

Our domain has one business rule: an account can’t send more money than it has. That’s an ordinary day at the bank, so the account answers with a return value instead of an exception. Create Banking/Domain/Result.cs:

namespace Banking.Domain;

public enum ErrorType
{
    NotFound,
    Conflict
}

public sealed record Error(string Code, string Description, ErrorType Type);

public class Result
{
    protected Result(Error? error) => Error = error;

    public Error? Error { get; }
    public bool IsSuccess => Error is null;

    public static Result Success() => new(null);

    public static implicit operator Result(Error error) => new(error);
}

public sealed class Result<TValue> : Result
{
    private readonly TValue? _value;

    private Result(TValue? value, Error? error)
        : base(error) => _value = value;

    public TValue Value => IsSuccess
        ? _value!
        : throw new InvalidOperationException("A failed result has no value.");

    public static implicit operator Result<TValue>(TValue value) =>
        new(value, null);

    public static implicit operator Result<TValue>(Error error) =>
        new(default, error);
}

A Result tells us whether the operation worked, and if it didn’t, its Error says why. Our Result pattern in .NET Web API article explains the idea in full.

Each error is defined beside the entity whose rule returns it. Create Banking/Domain/AccountErrors.cs:

namespace Banking.Domain;

public static class AccountErrors
{
    public static Error NotFound(int accountId) =>
        new("Account.NotFound", $"Account {accountId} was not found.", ErrorType.NotFound);

    public static Error InsufficientFunds(decimal balance, decimal requested) =>
        new("Account.InsufficientFunds", $"The balance is {balance:0.00}, {requested:0.00} requested.", ErrorType.Conflict);
}

Now the account. Create Banking/Domain/Account.cs:

namespace Banking.Domain;

public sealed class Account
{
    private Account(string owner, decimal balance)
    {
        Owner = owner;
        Balance = balance;
    }

    public int Id { get; private set; }
    public string Owner { get; private set; }
    public decimal Balance { get; private set; }

    public static Account Open(string owner, decimal balance)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(owner);
        ArgumentOutOfRangeException.ThrowIfNegative(balance);

        return new Account(owner, balance);
    }

    public Result Withdraw(decimal amount)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);

        if (amount > Balance)
            return AccountErrors.InsufficientFunds(Balance, amount);

        Balance -= amount;

        return Result.Success();
    }

    public void Deposit(decimal amount)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);

        Balance += amount;
    }
}

Every setter is private, so the only way to change a balance is through Withdraw() or Deposit(). When the balance is too low, Withdraw() returns an error and changes nothing.

The transfer record is the second thing our use case writes. Create Banking/Domain/Transfer.cs:

namespace Banking.Domain;

public sealed class Transfer
{
    private Transfer(int fromAccountId, int toAccountId, decimal amount, string reference)
    {
        FromAccountId = fromAccountId;
        ToAccountId = toAccountId;
        Amount = amount;
        Reference = reference;
    }

    public int Id { get; private set; }
    public int FromAccountId { get; private set; }
    public int ToAccountId { get; private set; }
    public decimal Amount { get; private set; }
    public string Reference { get; private set; }

    public static Transfer Create(Account from, Account to, decimal amount, string reference)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(reference);

        return new Transfer(from.Id, to.Id, amount, reference);
    }
}

Reference is the client’s name for the transfer, like the reference on your bank statement.

Data Access Abstraction

First, let’s create an abstraction over our data access layer. We write one small repository per aggregate, with named methods for exactly what the use case does. Create Banking/Application/IAccountRepository.cs:

using Banking.Domain;

namespace Banking.Application;

public interface IAccountRepository
{
    Task<Account?> GetByIdAsync(int accountId, CancellationToken cancellationToken = default);
}

Then the one for transfers. Create Banking/Application/ITransferRepository.cs:

using Banking.Domain;

namespace Banking.Application;

public interface ITransferRepository
{
    void Add(Transfer transfer);
}

The most important thing to note here is that we do not save anything inside the repositories. Therefore, the addition of an entity won’t persist immediately, rather it will be held in memory until we call SaveChangesAsync() on the unit of work.

Now, let’s declare the unit of work itself. Create Banking/Application/IUnitOfWork.cs:

namespace Banking.Application;

public interface IUnitOfWork
{
    Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

It returns a plain Task, because our use case has no use for the count EF Core returns. Moreover, the usage of transactions is an implementation detail of the unit of work, so let’s not expose that to the caller.

Next, the transfer use case. Create Banking/Application/TransferMoney.cs:

using Banking.Domain;

namespace Banking.Application;

public sealed record TransferMoneyCommand(int FromAccountId, int ToAccountId, decimal Amount, string Reference);

public sealed record TransferReceipt(int TransferId, decimal FromBalance, decimal ToBalance);

public sealed class TransferMoneyHandler(
    IAccountRepository accounts,
    ITransferRepository transfers,
    IUnitOfWork unitOfWork)
{
    public async Task<Result<TransferReceipt>> HandleAsync(
        TransferMoneyCommand command, CancellationToken cancellationToken = default)
    {
        var from = await accounts.GetByIdAsync(command.FromAccountId, cancellationToken);
        if (from is null)
            return AccountErrors.NotFound(command.FromAccountId);

        var to = await accounts.GetByIdAsync(command.ToAccountId, cancellationToken);
        if (to is null)
            return AccountErrors.NotFound(command.ToAccountId);

        var withdrawal = from.Withdraw(command.Amount);
        if (!withdrawal.IsSuccess)
            return withdrawal.Error!;

        to.Deposit(command.Amount);

        var transfer = Transfer.Create(from, to, command.Amount, command.Reference);
        transfers.Add(transfer);

        await unitOfWork.SaveChangesAsync(cancellationToken);

        return new TransferReceipt(transfer.Id, from.Balance, to.Balance);
    }
}

The handler loads both accounts and asks from.Withdraw() for the money. A missing account or a refusal goes straight back as the handler’s result. Those early returns are safe because nothing has been written yet.

Only when every step succeeds does it deposit the money, add the transfer, and call SaveChangesAsync() once. The receipt reads transfer.Id after the save, since the database assigns the ID when the INSERT runs.

In bigger apps, even that one call can move out of the handlers, into a pipeline step that commits after every command.

A unit-of-work behavior is part of the hand-rolled mediator pipeline in our Clean Architecture in .NET course, which builds a complete event ticketing app in .NET 10.

Many samples hang the repositories off the unit of work instead, as unitOfWork.Accounts and unitOfWork.Transfers, and that works too. We inject each repository on its own, so the handler’s constructor shows exactly what the use case touches.

Where do these interfaces belong? In the Application layer, because the use cases are what need them. In a layered solution, Infrastructure references Application and Application never references Infrastructure. Then the compiler stops you the moment a handler reaches for DbContext.

Some teams put these interfaces in Domain instead, the way Onion Architecture does. That works too, as long as Domain doesn’t reference EF Core either.

The Unit of Work Implementation

Now that we created an abstraction for the data access, let’s implement it with EF Core. If you haven’t worked with EF Core yet, our introduction to Entity Framework Core covers the basics.

First, the context. Create Banking/Infrastructure/BankDbContext.cs:

using Banking.Domain;
using Microsoft.EntityFrameworkCore;

namespace Banking.Infrastructure;

public sealed class BankDbContext(DbContextOptions<BankDbContext> options)
    : DbContext(options)
{
    public DbSet<Account> Accounts => Set<Account>();
    public DbSet<Transfer> Transfers => Set<Transfer>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Account>().HasData(
            new { Id = 1, Owner = "Alice", Balance = 100m },
            new { Id = 2, Owner = "Bob", Balance = 50m });

        modelBuilder.Entity<Transfer>()
            .HasIndex(t => t.Reference)
            .IsUnique();
    }
}

HasData() seeds our two accounts. The unique index on Reference is the database’s promise that it won’t store the same transfer twice.

Next, the repositories. Create Banking/Infrastructure/AccountRepository.cs:

using Banking.Application;
using Banking.Domain;
using Microsoft.EntityFrameworkCore;

namespace Banking.Infrastructure;

public sealed class AccountRepository(BankDbContext dbContext) : IAccountRepository
{
    public Task<Account?> GetByIdAsync(int accountId, CancellationToken cancellationToken = default) =>
        dbContext.Accounts.FirstOrDefaultAsync(a => a.Id == accountId, cancellationToken);
}

Because GetByIdAsync() hands back a tracked Account, the context records whatever Withdraw() or Deposit() changes. Create Banking/Infrastructure/TransferRepository.cs:

using Banking.Application;
using Banking.Domain;

namespace Banking.Infrastructure;

public sealed class TransferRepository(BankDbContext dbContext) : ITransferRepository
{
    public void Add(Transfer transfer) => dbContext.Transfers.Add(transfer);
}

Add() only tells the context about the new transfer. Nothing goes to the database yet.

Now, let’s create the actual UnitOfWork class. Create Banking/Infrastructure/UnitOfWork.cs:

using Banking.Application;

namespace Banking.Infrastructure;

public sealed class UnitOfWork(BankDbContext dbContext) : IUnitOfWork
{
    public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
        dbContext.SaveChangesAsync(cancellationToken);
}

We request the BankDbContext from the dependency injection container through the constructor. That’s the whole implementation, because the context already tracks every change.

Configuring Dependency Injection

With our data access layer in place, we can connect it to the unit of work via the dependency injection framework. Program.cs registers the services, creates a fresh database, and runs three transfers, each in its own scope. Replace Banking/Program.cs with:

using Banking.Application;
using Banking.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

services.AddDbContext<BankDbContext>(options =>
{
    options.UseSqlite("Data Source=bank.db");

    if (args.Contains("--log-sql"))
        options.LogTo(Console.WriteLine,
            [RelationalEventId.TransactionStarted, RelationalEventId.TransactionCommitted,
             RelationalEventId.TransactionDisposed, RelationalEventId.CommandExecuted,
             RelationalEventId.CommandError],
            options: DbContextLoggerOptions.None);
});

services.AddScoped<IAccountRepository, AccountRepository>();
services.AddScoped<ITransferRepository, TransferRepository>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped<TransferMoneyHandler>();

await using var provider = services.BuildServiceProvider(validateScopes: true);

await using (var scope = provider.CreateAsyncScope())
{
    var db = scope.ServiceProvider.GetRequiredService<BankDbContext>();
    await db.Database.EnsureDeletedAsync();
    await db.Database.EnsureCreatedAsync();
}

await TransferAsync(new TransferMoneyCommand(1, 2, 30m, "TR-1001"));
await TransferAsync(new TransferMoneyCommand(1, 2, 500m, "TR-1002"));
await TransferAsync(new TransferMoneyCommand(1, 2, 30m, "TR-1001"));
await PrintBalancesAsync();

async Task TransferAsync(TransferMoneyCommand command)
{
    await using var scope = provider.CreateAsyncScope();
    var handler = scope.ServiceProvider.GetRequiredService<TransferMoneyHandler>();

    try
    {
        var result = await handler.HandleAsync(command);

        Console.WriteLine(result.IsSuccess
            ? $"{command.Reference}: done, transfer {result.Value.TransferId}, new balance {result.Value.FromBalance:0.00}"
            : $"{command.Reference}: refused, {result.Error!.Description}");
    }
    catch (DbUpdateException ex)
    {
        Console.WriteLine($"{command.Reference}: failed, {ex.InnerException?.Message}");
    }
}

async Task PrintBalancesAsync()
{
    await using var scope = provider.CreateAsyncScope();
    var db = scope.ServiceProvider.GetRequiredService<BankDbContext>();

    foreach (var account in await db.Accounts.AsNoTracking().ToListAsync())
        Console.WriteLine($"{account.Owner}: {account.Balance:0.00}");

    Console.WriteLine($"Transfers: {await db.Transfers.CountAsync()}");
}

The ServiceCollection creation is necessary because we are using a simple console application, but this is created automatically in the ASP.NET Core templates. The --log-sql switch makes EF Core print its SQL and transaction events, and EnsureDeletedAsync() with EnsureCreatedAsync() gives us a fresh bank.db on every run.

It’s also important to consider the lifetime that we register. By using the Scoped lifetime, inside the same scope (or request for web applications) the UnitOfWork instance will remain the same.

So will the BankDbContext, and that is what makes the pattern work: the repositories put changes into the context, and the unit of work saves that same context.

What if each of them gets its own context? We registered the context as Transient to find out.

The handler printed TR-1001: done, transfer 0, new balance 70.00, and the database still showed Alice at 100.00, Bob at 50.00 and no transfers. The unit of work saved a context that had never seen a change, and nothing threw.

Here’s the difference:

Two panels, one above the other. In the top panel, one scope holds the transfer handler, the two repositories and the unit of work, and all three share one BankDbContext that tracks Alice, Bob and transfer TR-1001, so the save writes three changes. In the bottom panel, the context is registered as transient: each class gets its own context, the unit of work's context tracks nothing, and its save writes nothing without an error.

AddDbContext() registers the context as scoped by default, and ASP.NET Core creates a scope for every request. A console app has to create its scopes itself, and so does a background service, as our article on injecting a DbContext into an IHostedService explains.

The only lifetime we should avoid for the UnitOfWork is the Singleton, since it would hold on to one scoped context for the entire lifetime of the application. With scope validation on, the container refuses to resolve it:

Cannot consume scoped service 'Banking.Infrastructure.BankDbContext' from singleton 'Banking.Application.IUnitOfWork'.

ASP.NET Core turns that check on in the Development environment. A plain ServiceCollection doesn’t, which is why we pass validateScopes: true.

Running the Application

Our three transfers are TR-1001, which moves 30.00 from Alice to Bob, TR-1002, which asks for 500.00, and TR-1001 again, like a retry after a timeout. In the solution folder, let’s type:

dotnet run --project Banking

Here’s the output:

TR-1001: done, transfer 1, new balance 70.00
TR-1002: refused, The balance is 70.00, 500.00 requested.
TR-1001: failed, SQLite Error 19: 'UNIQUE constraint failed: Transfers.Reference'.
Alice: 70.00
Bob: 80.00
Transfers: 1

If your system writes decimals with a comma, you’ll see 70,00 instead of 70.00.

The first transfer went through. The second stopped at Withdraw(), so the handler returned before SaveChangesAsync() and wrote nothing.

The third one withdrew from Alice and deposited to Bob again, and then the database refused the duplicate reference. Yet Alice still has 70.00, and Bob has 80.00. But did the retry’s changes ever reach the database? Let’s run the app again, this time with the log on:

dotnet run --project Banking -- --log-sql

EF Core logs every command, including the ones that create the database. Here’s the part for the retried TR-1001, right after its two SELECT statements:

Began transaction with isolation level 'Serializable'.
Executed DbCommand (0ms) [Parameters=[@p1='?' (DbType = Int32), @p0='?' (DbType = Decimal)], CommandType='Text', CommandTimeout='30']
UPDATE "Accounts" SET "Balance" = @p0
WHERE "Id" = @p1
RETURNING 1;
Executed DbCommand (0ms) [Parameters=[@p1='?' (DbType = Int32), @p0='?' (DbType = Decimal)], CommandType='Text', CommandTimeout='30']
UPDATE "Accounts" SET "Balance" = @p0
WHERE "Id" = @p1
RETURNING 1;
Failed executing DbCommand (0ms) [Parameters=[@p0='?' (DbType = Decimal), @p1='?' (DbType = Int32), @p2='?' (Size = 7), @p3='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
INSERT INTO "Transfers" ("Amount", "FromAccountId", "Reference", "ToAccountId")
VALUES (@p0, @p1, @p2, @p3)
RETURNING "Id";
Disposing transaction.
TR-1001: failed, SQLite Error 19: 'UNIQUE constraint failed: Transfers.Reference'.

Both UPDATE statements ran inside the transaction, and then the INSERT failed on the unique index. Now look for the line that isn’t there: Committed transaction. EF Core disposed of the transaction without committing it, and SQLite threw away both updates.

The timings in parentheses will be different on your machine.

Testing The Unit of Work Implementation

Does our handler ask for exactly one commit when a transfer succeeds, and for none when it fails? Let’s add a project for the tests:

dotnet new classlib -n Banking.Tests
dotnet solution add Banking.Tests

This project gets a new project file, and its Class1.cs goes. Replace Banking.Tests/Banking.Tests.csproj with:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
    <PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Banking\Banking.csproj" />
  </ItemGroup>

</Project>

The package name’s mtp-off suffix keeps xUnit v3 on the classic runner, so dotnet test works with no options.

Each fake implements one interface in as few lines as it can. Create Banking.Tests/TransferMoneyHandlerTests.cs:

using Banking.Application;
using Banking.Domain;

namespace Banking.Tests;

public class TransferMoneyHandlerTests
{
    [Fact]
    public async Task HandleAsync_WhenFundsAreAvailable_MovesMoneyAndSavesOnce()
    {
        var alice = Account.Open("Alice", 100m);
        var bob = Account.Open("Bob", 50m);
        var transfers = new FakeTransferRepository();
        var unitOfWork = new FakeUnitOfWork();
        var handler = new TransferMoneyHandler(
            new FakeAccountRepository(new() { [1] = alice, [2] = bob }), transfers, unitOfWork);

        var result = await handler.HandleAsync(
            new TransferMoneyCommand(1, 2, 30m, "TR-1"), TestContext.Current.CancellationToken);

        Assert.True(result.IsSuccess);
        Assert.Equal(70m, alice.Balance);
        Assert.Equal(80m, bob.Balance);
        Assert.Single(transfers.Added);
        Assert.Equal(1, unitOfWork.SaveCount);
    }

    [Fact]
    public async Task HandleAsync_WhenFundsAreShort_SavesNothing()
    {
        var alice = Account.Open("Alice", 100m);
        var bob = Account.Open("Bob", 50m);
        var transfers = new FakeTransferRepository();
        var unitOfWork = new FakeUnitOfWork();
        var handler = new TransferMoneyHandler(
            new FakeAccountRepository(new() { [1] = alice, [2] = bob }), transfers, unitOfWork);

        var result = await handler.HandleAsync(
            new TransferMoneyCommand(1, 2, 500m, "TR-2"), TestContext.Current.CancellationToken);

        Assert.False(result.IsSuccess);
        Assert.Equal("Account.InsufficientFunds", result.Error!.Code);
        Assert.Equal(100m, alice.Balance);
        Assert.Empty(transfers.Added);
        Assert.Equal(0, unitOfWork.SaveCount);
    }
}

internal sealed class FakeAccountRepository(Dictionary<int, Account> accounts) : IAccountRepository
{
    public Task<Account?> GetByIdAsync(int accountId, CancellationToken cancellationToken = default) =>
        Task.FromResult(accounts.GetValueOrDefault(accountId));
}

internal sealed class FakeTransferRepository : ITransferRepository
{
    public List<Transfer> Added { get; } = [];

    public void Add(Transfer transfer) => Added.Add(transfer);
}

internal sealed class FakeUnitOfWork : IUnitOfWork
{
    public int SaveCount { get; private set; }

    public Task SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        SaveCount++;
        return Task.CompletedTask;
    }
}

FakeUnitOfWork only counts commits. The first test expects one after a transfer of 30.00, and the second expects none after a refused transfer of 500.00. Let’s run them:

dotnet test

As you can see, both pass:

Test summary: total: 2, failed: 0, succeeded: 2, skipped: 0, duration: 3.8s
Build succeeded in 5.6s

What the fakes can’t show us is the rollback itself, because that’s EF Core’s job, and the log above already showed it.

Here’s everything we created:

Banking.slnx
Banking/
    Banking.csproj, Program.cs
    Domain/
        Account.cs, AccountErrors.cs, Result.cs, Transfer.cs
    Application/
        IAccountRepository.cs, ITransferRepository.cs, IUnitOfWork.cs, TransferMoney.cs
    Infrastructure/
        AccountRepository.cs, BankDbContext.cs, TransferRepository.cs, UnitOfWork.cs
Banking.Tests/
    Banking.Tests.csproj, TransferMoneyHandlerTests.cs

Only Infrastructure and Program.cs use EF Core.

Unit of Work Best Practices

To reflect more on the unit of work’s lifetime, it’s a good idea to keep it as short as possible. EF Core’s guide to DbContext lifetime describes each context as meant for a single unit of work, so we use one scope per operation.

Keep Changes in the Change Tracker

SaveChangesAsync() only writes what the context tracks. ExecuteUpdateAsync() and ExecuteDeleteAsync() skip tracking: they run their SQL the moment we call them and don’t start a transaction, as EF Core’s documentation on these methods points out.

Here’s a deposit that looks like a harmless shortcut. Don’t use it inside a unit of work:

public Task DepositAsync(int accountId, decimal amount, CancellationToken cancellationToken = default) =>
    dbContext.Accounts
        .Where(a => a.Id == accountId)
        .ExecuteUpdateAsync(setters => setters.SetProperty(a => a.Balance, a => a.Balance + amount), cancellationToken);

We swapped it into our handler in place of to.Deposit() and sent the retry again. What do you think happened?

Alice’s withdrawal was rolled back with the failed transaction, but Bob kept the deposit and ended at 110.00. The log showed why: the UPDATE from ExecuteUpdateAsync() ran before Began transaction, so it committed on its own.

When we really need a bulk update in EF Core next to tracked changes, we open the transaction ourselves:

await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);

await dbContext.Accounts
    .Where(a => a.Id == toAccountId)
    .ExecuteUpdateAsync(setters => setters.SetProperty(a => a.Balance, a => a.Balance + amount), cancellationToken);

await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);

Now SaveChangesAsync() joins the open transaction, and the failed INSERT took both balance changes with it. If a use case has to control the transaction, IUnitOfWork can grow a BeginTransactionAsync() method, but most use cases never need it.

Unit of Work With Dapper

Dapper has no change tracker, so with Dapper the unit of work is the transaction itself: we open a connection, begin a transaction, pass it to every command and commit at the end. Our article on using Dapper with ASP.NET Core Web API ends with a transaction example.

When Dapper and EF Core write together, we give Dapper EF Core’s connection and transaction, from Database.GetDbConnection() and GetDbTransaction() (in the Microsoft.EntityFrameworkCore.Storage namespace). When we ran it, a failed SaveChangesAsync() meant the commit never ran, so the Dapper update was rolled back too.

Use Optimistic Concurrency

A unit of work makes one operation all or nothing. It does nothing about a second operation running at the same time.

We loaded Alice in two scopes, withdrew 10.00 in one and 20.00 in the other, and saved both. Alice ended at 80.00 instead of 70.00, and nothing complained.

Speaking of concurrency, it’s a good idea to use optimistic concurrency in conjunction with the unit of work pattern. In our sample, one line makes Balance a concurrency token:

modelBuilder.Entity<Account>().Property(a => a.Balance).IsConcurrencyToken();

With it, the first save left Alice at 90.00, and the second threw a DbUpdateConcurrencyException instead of overwriting it. Because the whole unit of work fails together, we can reload the account and try again.

What Are the Drawbacks of the Unit of Work Pattern?

As we saw, the unit of work pattern has some significant advantages. Yet, it’s not flawless. However, most of the time its pros outweigh its drawbacks. Let’s summarize them.

Complexity

Introducing abstraction always increases code complexity too. Sometimes this complexity increase is not justified, especially in the case of simpler applications. If each use case writes through one repository, that repository’s own save is enough. And if our handlers use DbContext directly, we already have a unit of work.

Learning Curve

The unit of work pattern has a moderate learning curve for someone who is not familiar with the concept. For developers new to this pattern it requires a mindset change, how they think about data persistence and transactions.

Delayed State Changes

By grouping operations together, we have to give up some advantages of direct database access. This is also connected to the required mindset change we discussed earlier.

Let’s take an example scenario, where we want to create two related entities in the same transaction: an order and a payment. Assume that we have database-generated IDs for both entities.

In our code, when we create an order, it won’t possess an ID at that moment. This is because the ID will be generated by the database, but the unit of work has not saved the order as of now. Our transfer.Id was 0 right before SaveChangesAsync() and 1 right after it.

Does that mean we can’t link the payment to the order? No. We set a navigation property, and EF Core fills in the foreign key during the save. In a quick run, payment.OrderId matched order.Id after one save.

Another issue related to delays occurs when we retrieve an entity from the database, make modifications within the unit of work, and then query the database again before saving.

We withdrew 50.00 from Alice without saving, and asked for every account with a balance under 80.00. Only Bob came back, because the table still said Alice had 100.00, while the tracked Alice object said 50.00.

When Should We Use the Unit of Work Pattern?

We use it when one business operation writes through more than one repository, and those writes must succeed or fail together. Here’s how we’d decide for an EF Core app:

Your codeWhat we'd do
Handlers use DbContext directlyNothing extra. One SaveChangesAsync() already commits the operation as one unit
Each use case writes through one repositoryLet that repository expose SaveChangesAsync()
One use case writes through two or more repositoriesAdd IUnitOfWork and commit once, at the end
Bulk updates or Dapper next to tracked changesOpen an explicit transaction around all of it
Two requests can change the same rows at onceAdd a concurrency token as well

Most apps live in the first three rows. The last two are where some of the surprises in this article came from.

Conclusion

In this article, we’ve learned about the unit of work pattern. We started with an explanation of the pattern and summarized the pros and cons of using it. We also implemented the pattern with EF Core behind an IUnitOfWork interface, and watched a failed transfer roll back on a real database.

We’ve also seen what it doesn’t cover: bulk updates, Dapper commands outside EF Core’s transaction and concurrent requests.

If your code saves inside its repositories today, that’s the place to start. Move the save to the end of the use case, and a transfer that fails halfway through leaves both accounts as they were.

Tested with .NET 10.0.10 (SDK 10.0.302), EF Core 10.0.12 and xUnit v3 4.0.1.