# Where Do Transactions Belong in Clean Architecture?

> The use case defines what must succeed or fail together, so the transaction boundary belongs to the application layer. Here are three ways to implement that in .NET, ranked: a single SaveChanges as the implicit boundary, a unit of work abstraction, and a transaction pipeline behavior.

Published: 2026-08-11. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/transactions-clean-architecture

Transactions belong in the application layer, because the **use case** defines what must succeed or fail together.
The mechanics (EF Core, `BeginTransaction`) stay in infrastructure behind an abstraction, and the domain never touches persistence.
Here are three ways to implement that boundary in .NET, ranked.

"Where do I put `BeginTransaction`?" comes up in every Clean Architecture codebase, and the answers people improvise are all over the place.
In the controller. In the repository. In a middleware that wraps every request.
All three put the boundary in the wrong place, because they let a technical concern decide a business question.

## The Principle: Atomicity Is a Use Case Property

Take a classic use case: transferring inventory between warehouses.
Deduct from one location, add to another, record the movement.
Partial success is corruption, so those writes are atomic **because the business says so**, not because EF Core has a transaction API.

That reasoning gives each layer its role:

![The application layer owns the transaction boundary, reasoning about which domain aggregates must stay consistent and delegating the transaction mechanics to the infrastructure layer](https://milanjovanovic.tech/blogs/articles/transactions-clean-architecture/transaction-boundary-ownership.png)

- **Domain**: defines aggregates, and an aggregate is itself a consistency boundary. Changes within one aggregate must always be atomic. The domain implies what must be consistent, but never touches persistence.
- **Application**: the use case knows which aggregates it modifies together, so it owns the transaction boundary.
- **Infrastructure**: implements the mechanics with EF Core, and the [**details of working with transactions**](https://milanjovanovic.tech/blog/working-with-transactions-in-ef-core) stay here.

Repositories are the notably wrong place.
A repository sees one aggregate type at a time, and it cannot know whether the current operation is standalone or part of a larger unit.
Controllers are equally wrong for the mirrored reason: they know about HTTP, not about business atomicity.

With the principle set, there are three ways to implement it.
I will rank them as we go.

## Option 1: One SaveChanges Per Use Case (the Default)

The fact that makes most explicit transactions unnecessary: **EF Core already wraps every `SaveChanges` call in a database transaction**.
All tracked changes flushed by that one call commit or roll back together.

So the simplest correct pattern is: a use case mutates any number of tracked entities, and calls `SaveChanges` exactly once at the end.

```csharp
public sealed record TransferInventoryCommand(
    Guid SourceLocationId,
    Guid DestinationLocationId,
    string Sku,
    int Quantity) : IRequest;

public sealed class TransferInventoryCommandHandler(
    IInventoryRepository inventoryRepository,
    IUnitOfWork unitOfWork)
    : IRequestHandler<TransferInventoryCommand>
{
    public async Task Handle(TransferInventoryCommand command, CancellationToken ct)
    {
        InventoryItem source = await inventoryRepository
            .GetAsync(command.SourceLocationId, command.Sku, ct)
            ?? throw new SourceInventoryNotFoundException(command.Sku);

        InventoryItem destination = await inventoryRepository
            .GetAsync(command.DestinationLocationId, command.Sku, ct)
            ?? throw new DestinationInventoryNotFoundException(command.Sku);

        source.Remove(command.Quantity);
        destination.Add(command.Quantity);

        await unitOfWork.SaveChangesAsync(ct);
    }
}
```

`IUnitOfWork` is a small application-layer interface, implemented by the `DbContext` in infrastructure:

```csharp
public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken ct = default);
}

// Infrastructure
public sealed class AppDbContext : DbContext, IUnitOfWork
{
    // DbSets and configuration
}
```

The `DbContext` was always a **unit of work**; the interface just lets the application layer own the abstraction, keeping the dependency arrow pointing inward.

This is my ranking's number one, and it should cover 90 percent of your use cases.
It has a discipline attached: **repositories never call SaveChanges**.
The moment a repository saves inside itself, the use case loses the ability to compose multiple changes into one atomic commit.

Domain events dispatched before `SaveChanges` (the standard interceptor approach) ride in the same transaction, so side effects like [**outbox messages**](https://milanjovanovic.tech/blog/implementing-the-outbox-pattern) commit atomically with the state change.
That combination solves the dual-write problem without any explicit transaction code.

## Option 2: An Explicit Transaction Abstraction (When One SaveChanges Is Not Enough)

Some use cases genuinely need more than one flush:

- You need a generated ID from an insert before a second write can proceed.
- You mix EF Core changes with Dapper or raw SQL that must commit together.
- You call an infrastructure service that writes to the same database outside the `DbContext`.

Then the application layer needs an explicit boundary, still behind its own abstraction:

```csharp
public interface ITransactionManager
{
    Task ExecuteInTransactionAsync(
        Func<CancellationToken, Task> action,
        CancellationToken ct = default);
}
```

Infrastructure implements it with EF Core, including the execution strategy so it composes with **connection resiliency**:

```csharp
public sealed class TransactionManager(AppDbContext dbContext) : ITransactionManager
{
    public async Task ExecuteInTransactionAsync(
        Func<CancellationToken, Task> action,
        CancellationToken ct = default)
    {
        IExecutionStrategy strategy = dbContext.Database.CreateExecutionStrategy();

        await strategy.ExecuteAsync(async token =>
        {
            await using IDbContextTransaction transaction =
                await dbContext.Database.BeginTransactionAsync(token);

            await action(token);

            await transaction.CommitAsync(token);
        }, ct);
    }
}
```

And the use case wraps only the part that must be atomic:

```csharp
await transactionManager.ExecuteInTransactionAsync(async token =>
{
    await orderRepository.AddAsync(order, token);
    await unitOfWork.SaveChangesAsync(token);

    await auditWriter.WriteAsync(order.Id, token);
}, ct);
```

One catch: `BeginTransactionAsync` opens the transaction on the `DbContext`'s connection, and it does not flow to other writers automatically.
For the audit writer to actually participate, its Dapper or ADO.NET code must run on that same connection and transaction, which infrastructure can obtain from `dbContext.Database.GetDbConnection()` and `dbContext.Database.CurrentTransaction.GetDbTransaction()`.
A writer on its own connection commits independently, and the atomicity you think you have is not there.

This is rank two: exactly as much transaction as the use case needs, no more.
Use it for the minority of use cases that need it, and keep Option 1 everywhere else.
Mixing the two in one codebase is normal.

## Option 3: A Transaction Pipeline Behavior (Blanket Coverage)

The third approach wraps **every command** in a transaction via a [**MediatR pipeline behavior**](https://milanjovanovic.tech/blog/mediatr-pipeline-behaviors):

```csharp
public interface ITransactionalCommand { }

public sealed class TransactionBehavior<TRequest, TResponse>(
    AppDbContext dbContext)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : ITransactionalCommand
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        IExecutionStrategy strategy = dbContext.Database.CreateExecutionStrategy();

        return await strategy.ExecuteAsync(async token =>
        {
            await using IDbContextTransaction transaction =
                await dbContext.Database.BeginTransactionAsync(token);

            TResponse response = await next();

            await transaction.CommitAsync(token);

            return response;
        }, ct);
    }
}
```

The behavior depends on `AppDbContext` directly, so it lives in infrastructure; have it use `ITransactionManager` instead if you want it in the application layer.

I rank this third, and I use it rarely.
The upsides are real: handlers stay free of transaction code, and no one can forget the boundary.
But the costs are structural:

- Most handlers do not need it, because one `SaveChanges` already gave them atomicity. The blanket transaction just holds a connection and locks for longer than necessary.
- It invites multi-`SaveChanges` handlers to proliferate, because "the behavior will catch it". Implicit safety breeds sloppy boundaries.
- Handlers that call external services now do so inside an open database transaction, stretching lock duration across network calls.

If you adopt it, constrain it with a marker interface (like `ITransactionalCommand` above) so only opt-in commands pay the cost, and never wrap queries.

## What About TransactionScope and Request-Level Middleware?

Two approaches I recommend against as defaults.

`TransactionScope` with ambient flow looks convenient, but it is easy to hold wrong.
The scope does not flow across `await` unless you create it with `TransactionScopeAsyncFlowOption.Enabled`, and forgetting that means work after the first `await` silently runs outside the transaction.
A second connection can escalate it to a distributed transaction, and the boundary hides in ambient state where nobody can see it.
Explicit boundaries are easier to reason about and easier to test.

A transaction-per-request middleware puts the boundary at the HTTP layer, which is the controller mistake at scale: every read-only GET pays for transaction overhead, and the boundary no longer matches any business definition of atomicity.
The use case is the boundary; the request is just transport.

I walk through this decision, including how the outbox extends atomicity to messaging, in [**Pragmatic Clean Architecture**](https://milanjovanovic.tech/pragmatic-clean-architecture).

## Summary

Transactions answer a business question (what must happen together), so the boundary belongs to the layer that models business operations: the application layer.

Ranked:

1. **One SaveChanges per use case.** EF Core's implicit transaction covers it. This is the default, and repositories must not save behind the use case's back.
2. **An explicit ITransactionManager** for the few use cases that need multiple flushes or mixed writers, wrapping only what must be atomic.
3. **A pipeline behavior** when you want blanket enforcement, constrained by a marker interface and kept away from queries.

If you find `BeginTransaction` in a controller or a repository today, move the boundary to the handler.
The code gets shorter, and atomicity finally matches what the business actually meant.

## Frequently asked questions

### Which layer manages transactions in Clean Architecture?

The application layer defines the transaction boundary, because the use case decides what must be atomic. The mechanics (EF Core, ADO.NET) stay in infrastructure behind an abstraction like IUnitOfWork.

### Do I need BeginTransaction with EF Core?

Usually not. A single SaveChanges call already wraps all tracked changes in one database transaction. You only need an explicit transaction when a use case requires multiple SaveChanges calls or mixes EF with raw SQL that must commit atomically.

### Should the domain layer start transactions?

No. The domain defines invariants and aggregate boundaries, which imply what must be consistent, but it never touches persistence. Starting transactions is orchestration, and orchestration is the application layer's job.

### Is a transaction per HTTP request a good idea?

It is convenient but blunt. Middleware-level transactions hold database connections and locks for the whole request, including work that needs no transaction, and they break down for requests that trigger multiple independent use cases. Prefer use-case-level boundaries.
