Photo by Dash Khatami / Unsplash

Stop Rolling Your Own Transaction Management in .NET

architecture Apr 18, 2026

You have a LeasePaymentHandler that calls an InvoiceService to update the invoice balance. Both operations need to commit or fail together. Simple enough.

So you create an IUnitOfWork that wraps DbContext.Database.BeginTransactionAsync(), inject it into both services, and add a rule: "only the outermost caller touches the UoW." The inner service just does its work and trusts that someone upstream started a transaction.

This works for about six months. Then someone calls the inner service directly from a new controller, forgets to wrap it in a UoW, and now you have a partially committed invoice balance with no corresponding payment record.

This post walks through the three common transaction approaches in .NET with EF Core, explains where each breaks, and argues that TransactionScope is the one you should actually be using.

The Three Approaches

1. DbContext.Database.BeginTransactionAsync()

Raw EF Core transaction control. You call BeginTransactionAsync(), do your work, then CommitAsync() or RollbackAsync().

await using var transaction = await _dbContext.Database.BeginTransactionAsync();
try
{
    // operations...
    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

The immediate problem: this lives on the DbContext, which means your services now depend on the context directly. If you've built a repository layer to keep DbContext out of your services, you've just punched a hole through it.

2. Custom IUnitOfWork

A wrapper abstraction over the EF Core transaction. Typically registered as scoped (one per HTTP request), injected alongside repositories.

public interface IUnitOfWork : IAsyncDisposable
{
    Task BeginAsync();
    Task CommitAsync();
    Task RollbackAsync();
}

This is what most .NET projects end up building. It keeps DbContext out of services and gives you a clean Begin/Commit/Rollback API. The implementation calls BeginTransactionAsync() under the hood.

3. TransactionScope

A System.Transactions primitive that creates an ambient transaction. Any database connection opened within the scope automatically enlists. No explicit begin/commit ceremony; the scope commits when you call Complete(), and rolls back if disposed without it.

using var scope = new TransactionScope(
    TransactionScopeOption.Required,
    new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
    TransactionScopeAsyncFlowOption.Enabled);

// operations...
scope.Complete();

Where IUnitOfWork Falls Apart

The IUnitOfWork pattern has a structural flaw that only shows up when your service graph gets deep enough. Here's the scenario:

sequenceDiagram participant Controller participant ServiceA participant ServiceB participant UoW as IUnitOfWork Controller->>ServiceA: CreateLeaseAsync() ServiceA->>UoW: BeginAsync() ServiceA->>ServiceB: UpdateInvoicePaymentAsync() Note over ServiceB: Needs atomicity too,
but can't call BeginAsync()
— would throw! ServiceB-->>ServiceA: returns ServiceA->>UoW: CommitAsync()

ServiceB needs its own transactional guarantee. Maybe it's called from five different places; two of them already have an active UoW, three don't. So what does ServiceB do?

Option A: ServiceB never starts a UoW. It trusts that the caller did. If the caller forgot, the operations aren't transactional. No compiler warning, no runtime error until data corrupts.

Option B: ServiceB checks HasActiveTransaction. Conditional transaction logic scattered across services. Every method has an if/else deciding whether to begin its own transaction. Ugly, error-prone, and the kind of code that makes you question your career choices at 11pm.

Option C: BeginAsync() throws on double-call. Our approach. We threw DuplicateUnitOfWorkException if you called BeginAsync() when a transaction already existed. This at least fails loud, but it means services can never independently declare atomicity. The call hierarchy is a rigid contract that isn't encoded anywhere the compiler can check.

All three options share the same root cause: IUnitOfWork is a single shared mutable resource scoped to the request. It can't compose.

Where BeginTransaction Falls Apart

DbContext.Database.BeginTransactionAsync() has the same nesting problem as IUnitOfWork (it IS what IUnitOfWork wraps), plus it leaks the DbContext into your service layer. If you've set up repositories to abstract data access, using BeginTransactionAsync() directly means your services couple to the ORM.

There's also a subtlety people miss: EF Core's BeginTransactionAsync() returns an IDbContextTransaction that's specific to that context instance. If you have a second DbContext (say, a BatchRepository using IDbContextFactory), its operations won't participate in the first context's transaction. You'd need to share the underlying DbConnection and manually call UseTransaction() on the second context. Fragile and easy to get wrong.

Why TransactionScope Works

TransactionScope fixes the composition problem because it's ambient. Services don't pass transaction objects around; they don't check if a transaction exists; they don't coordinate with callers. Each service independently says "I need atomicity" and the runtime handles the rest.

sequenceDiagram participant Controller participant ServiceA participant ServiceB participant Ambient as Transaction.Current Controller->>ServiceA: CreateLeaseAsync() ServiceA->>Ambient: new TransactionScope(Required) Note over Ambient: Creates new transaction ServiceA->>ServiceB: UpdateInvoicePaymentAsync() ServiceB->>Ambient: new TransactionScope(Required) Note over Ambient: Joins existing transaction ServiceB->>ServiceB: does work ServiceB->>Ambient: scope.Complete() Note over Ambient: Inner scope votes "commit"
but doesn't actually commit yet ServiceB-->>ServiceA: returns ServiceA->>Ambient: scope.Complete() Note over Ambient: Outermost scope commits
the whole thing

Both services declare their own transactional needs. If ServiceA calls ServiceB, the inner scope joins the outer transaction via TransactionScopeOption.Required. If ServiceB is called directly from a controller with no outer scope, it creates its own transaction. No conditional logic. No implicit contracts.

The rollback story is even cleaner. With IUnitOfWork, we had 20+ catch blocks that looked like this:

catch
{
    await _unitOfWork.RollbackAsync();
    throw;
}

With TransactionScope, if the using block exits without Complete() being called (whether by exception, early return, or any other path), the transaction rolls back automatically on disposal. You can delete every explicit rollback call. Early returns inside the scope don't need special handling.

using var scope = TransactionHelper.Create();

if (invoice.Status == InvoiceStatus.Paid)
    return BadRequest("Already paid"); // scope disposes, auto-rollback, done

await _paymentRepository.CreateAsync(payment);
await _invoiceService.UpdateBalanceAsync(invoice.Id, amount);

scope.Complete(); // only reached on the happy path

The Gotchas (Because There Are Always Gotchas)

You will forget TransactionScopeAsyncFlowOption.Enabled

TransactionScope predates async/await. Without TransactionScopeAsyncFlowOption.Enabled, the ambient transaction doesn't flow across await boundaries. It silently disappears. Your operations run without a transaction and you won't know until your data is inconsistent.

Fix this once by wrapping the constructor in a factory method:

public static class TransactionHelper
{
    public static TransactionScope Create(
        TransactionScopeOption scopeOption = TransactionScopeOption.Required,
        IsolationLevel isolationLevel = IsolationLevel.ReadCommitted)
    {
        return new TransactionScope(
            scopeOption,
            new TransactionOptions { IsolationLevel = isolationLevel },
            TransactionScopeAsyncFlowOption.Enabled);
    }
}

Now nobody writes the raw constructor. One fewer class of bug.

Distributed transaction promotion will ruin your day

If two different database connections enlist in the same TransactionScope, .NET promotes to a distributed transaction (DTC). PostgreSQL doesn't support DTC. SQL Server does, but you don't want it; the performance hit is severe and the failure modes are nightmarish.

This matters if you have:

  • A BatchRepository that creates its own DbContext via IDbContextFactory
  • A standalone advisory lock that opens a separate connection
  • Any Dapper queries running on a manually opened connection
  • Hangfire's own internal connection to its job store

The fix: wrap those connection-opens in a suppressed scope.

using (TransactionHelper.CreateSuppressed())
{
    connection = await _dataSource.OpenConnectionAsync();
}
// connection is now outside any ambient transaction

TransactionScopeOption.Suppress tells the runtime "ignore whatever ambient transaction exists; this connection is independent." We use this for standalone PostgreSQL advisory locks (they manage their own connection lifecycle) and for bulk operations that go through a separate DbContext instance.

Npgsql needs Enlist=true

For Npgsql (the PostgreSQL .NET driver), connections only auto-enlist in ambient transactions when Enlist=true is in the connection string. This is technically the default, but set it explicitly. Don't leave it to chance in production.

var builder = new NpgsqlConnectionStringBuilder(connectionString)
{
    Enlist = true
};

You still need SaveChangesAsync — the scope doesn't flush for you

TransactionScope manages the transaction. It does not interact with EF Core's change tracker. If you never call SaveChangesAsync(), your changes sit in memory and nothing reaches the database, even after scope.Complete().

The flow is: SaveChangesAsync() sends the SQL (INSERT, UPDATE) to the database within the transaction. The rows are written but uncommitted. Then scope.Complete() marks the scope as successful, and when the scope disposes, the transaction commits.

repo.CreateAsync(entity)  →  SaveChangesAsync()  →  INSERT sent (uncommitted)
repo.UpdateAsync(other)   →  SaveChangesAsync()  →  UPDATE sent (uncommitted)
scope.Complete()          →  flags "ready to commit"
scope disposes            →  COMMIT

If your repository methods call SaveChangesAsync() on every operation (ours do), each write is flushed individually within the transaction. That means subsequent queries within the same scope can see the data you just wrote, which matters when you need to reference newly created entities.

You could alternatively skip per-operation SaveChangesAsync() calls and do a single flush at the end. EF Core would then batch the accumulated changes into fewer round trips. But any code that queries for an entity it just created (within the same scope) would get null back, because the row was never sent to the database. Fix those re-query patterns first if you want to go down that path.

What the Migration Looks Like

We migrated 22 service files in a production codebase. The transformation is mechanical:

flowchart LR subgraph Before ["Before (IUnitOfWork)"] B1["await _unitOfWork.BeginAsync()"] B2["try { ... operations ... }"] B3["await _unitOfWork.CommitAsync()"] B4["catch { await _unitOfWork.RollbackAsync(); throw; }"] B1 --> B2 --> B3 B2 --> B4 end subgraph After ["After (TransactionScope)"] A1["using var scope = TransactionHelper.Create()"] A2["... operations ..."] A3["scope.Complete()"] A1 --> A2 --> A3 end Before -- "replace" --> After

The steps for each file:

  1. Remove IUnitOfWork from the constructor and field declaration
  2. Add using EstateVault.Core.Transactions;
  3. Replace every BeginAsync/CommitAsync/RollbackAsync block with a using scope
  4. Delete every explicit rollback call

After migrating all services, we deleted the IUnitOfWork interface and implementation entirely. The compiler caught every missed reference. That's one advantage of a clean break: if you leave the old interface around "just in case," someone will use it and you'll have two transaction mechanisms fighting each other.

We also found a pre-existing bug during migration. One service called RollbackAsync() before BeginAsync() had ever been called. It would have thrown NullUnitOfWorkException if that code path ever executed. With TransactionScope, validation checks that happen before the using block don't need any rollback at all; there's nothing to roll back.

When NOT to Use TransactionScope

If you're running a single SaveChangesAsync() call with no service-to-service coordination, you don't need any of this. EF Core wraps single SaveChanges calls in their own transaction automatically.

If you need transactions that span multiple databases (Postgres + SQL Server, for example), TransactionScope will try to promote to DTC. That's a whole different problem space and you should look at saga patterns or outbox patterns instead.

If your ORM doesn't support ambient transaction enlistment, TransactionScope won't help. Check your driver's documentation. Npgsql and Microsoft.Data.SqlClient both support it. Many others do too, but verify first.

The Decision Framework

Question If yes...
Do services call other services that also need atomicity? TransactionScope.
Is one SaveChangesAsync per request enough? EF Core's implicit transaction. You don't need anything custom.
Do you need the same transaction across repository + Dapper + raw SQL? TransactionScope with Enlist=true.
Is DbContext already exposed in your service layer? BeginTransactionAsync() is fine, but consider whether you should fix that coupling.
Are you building a new project with a clean service layer? Start with TransactionScope from day one. You'll thank yourself in six months.

Wrapping Up

The IUnitOfWork pattern feels safe because it's explicit. You see the Begin and Commit right there in the code. But that explicitness becomes a liability once you have services calling services. You end up with implicit contracts ("the caller must start the UoW"), invisible coupling, and error-handling boilerplate that doesn't actually protect you from the one failure mode that matters: a service being called without a transaction when it needs one.

TransactionScope trades explicit begin/commit for ambient composition. Each service declares what it needs. The runtime composes transactions correctly. Rollback is automatic. And you delete a bunch of infrastructure code that was never adding value in the first place.

The migration took a day. We deleted four files, removed a dependency from 22 services, fixed a latent bug, and ended up with less code that handles more cases correctly. That's about as clean a win as infrastructure work gets.

Tags

Views: Loading...