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: the controller, the repository, or 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:
- 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 stay here.
Repositories are the 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 wrong too, because they know about HTTP and nothing about business atomicity.
Option 1: One SaveChanges Per Use Case (the Default)
Most explicit transactions are unnecessary, because 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 that mutates any number of tracked entities and calls SaveChanges exactly once at the end.
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:
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 lets the application layer own the abstraction, so the dependency arrow keeps pointing inward.
This is my ranking's number one, and it should cover 90 percent of your use cases. It only works if 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 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 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:
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:
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:
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, so a rollback does not undo its write.
This is rank two, because it adds a transaction only where the use case needs one. 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 commands in a transaction via a MediatR pipeline behavior.
Without a constraint it would wrap every request, queries included; this version only wraps commands that implement ITransactionalCommand:
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. Handlers stay free of transaction code, and no one can forget the boundary. But it has three costs:
- Most handlers do not need it, because one
SaveChangesalready gave them atomicity. The blanket transaction just holds a connection and locks for longer than necessary. - It invites multi-
SaveChangeshandlers to proliferate, because "the behavior will catch it". - Handlers that call external services now do so inside an open database transaction, so locks stay held 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 misuse.
The scope does not flow across await unless you create it with TransactionScopeAsyncFlowOption.Enabled, and forgetting that means work after the first await runs outside the transaction.
A second connection can escalate it to a distributed transaction, and the boundary is ambient state that no method signature shows.
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.
I walk through this decision, including how the outbox extends atomicity to messaging, in Pragmatic Clean Architecture.
Summary
Transactions answer a business question (what must happen together), so the boundary belongs to the application layer, which models business operations.
Ranked:
- One SaveChanges per use case. EF Core's implicit transaction covers it. This is the default, and repositories must not call SaveChanges themselves.
- An explicit ITransactionManager for the few use cases that need multiple flushes or mixed writers, wrapping only what must be atomic.
- 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.
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.



