Back to blog
Backend Systemsintermediate

Wrap Multiple Operations in One Safe Transaction

SaveChanges wraps a single call in a transaction automatically, but multi-step workflows need explicit control. Learn BeginTransactionAsync, TransactionScope, isolation levels, and the Unit of Work pattern.

LearnixoApril 14, 20265 min read
.NETC#EF CoreTransactionsEntity Framework
Share:𝕏

When SaveChanges Is Already Enough

A single SaveChangesAsync() call is already wrapped in a database transaction. If you add three entities in one call and the third insert fails, none are committed. For most CRUD operations you need nothing extra.

C#
// All three inserts are atomic — no explicit transaction needed
db.Orders.Add(order);
db.OrderItems.AddRange(items);
db.Invoices.Add(invoice);
await db.SaveChangesAsync(); // one transaction, all or nothing

You need explicit transactions when you must:

  • Call SaveChangesAsync() multiple times in sequence and treat them all as one unit
  • Interleave raw SQL (ExecuteSqlRawAsync) with tracked entity saves
  • Coordinate across multiple DbContext instances

BeginTransactionAsync

C#
public async Task PlaceOrderAsync(CreateOrderRequest request)
{
    await using var transaction = await _db.Database.BeginTransactionAsync();

    try
    {
        // Step 1: reserve inventory (raw SQL for optimistic lock)
        int affected = await _db.Database.ExecuteSqlRawAsync(
            "UPDATE Products SET Stock = Stock - {0} WHERE Id = {1} AND Stock >= {0}",
            request.Quantity, request.ProductId);

        if (affected == 0)
            throw new InvalidOperationException("Insufficient stock.");

        // Step 2: create the order via EF tracked entities
        var order = new Order
        {
            ProductId = request.ProductId,
            Quantity  = request.Quantity,
            Status    = "Confirmed",
            CreatedAt = DateTime.UtcNow
        };
        _db.Orders.Add(order);
        await _db.SaveChangesAsync(); // first SaveChanges — inside transaction

        // Step 3: create payment record
        var payment = new Payment
        {
            OrderId   = order.Id,
            Amount    = request.TotalAmount,
            CreatedAt = DateTime.UtcNow
        };
        _db.Payments.Add(payment);
        await _db.SaveChangesAsync(); // second SaveChanges — same transaction

        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

BeginTransactionAsync returns an IDbContextTransaction. Always call RollbackAsync in the catch — though EF will roll back automatically when the connection closes, being explicit is clearer.

Using TransactionScope

TransactionScope is the .NET ambient transaction mechanism. It enlists all database operations within the scope automatically.

C#
using System.Transactions;

public async Task TransferFundsAsync(int fromAccountId, int toAccountId, decimal amount)
{
    // AsyncFlowOption.Enabled is REQUIRED for async code
    using var scope = new TransactionScope(
        TransactionScopeOption.Required,
        new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
        TransactionScopeAsyncFlowOption.Enabled);

    // Debit
    var from = await _db.Accounts.FindAsync(fromAccountId)
               ?? throw new KeyNotFoundException();
    from.Balance -= amount;
    await _db.SaveChangesAsync();

    // Credit
    var to = await _db.Accounts.FindAsync(toAccountId)
             ?? throw new KeyNotFoundException();
    to.Balance += amount;
    await _db.SaveChangesAsync();

    scope.Complete(); // commit — if not called, rolls back on Dispose
}

TransactionScopeAsyncFlowOption.Enabled is non-negotiable. Without it, async continuations run outside the scope and the transaction is lost.

Why to Avoid Distributed Transactions

TransactionScope can escalate to a distributed transaction (MSDTC) if you open connections to two different databases, or use certain providers. Distributed transactions:

  • Require MSDTC to be installed and configured
  • Don't work on Linux / in containers without extra setup
  • Have significant performance overhead
  • Are unsupported by most cloud databases

Avoid them. If you need cross-database atomicity, use the Outbox pattern, Saga orchestration, or rethink your data boundaries.

Transaction Isolation Levels

C#
await using var transaction = await _db.Database.BeginTransactionAsync(
    System.Data.IsolationLevel.RepeatableRead);

| Level | Dirty Read | Non-Repeatable Read | Phantom Read | Use For | |---|---|---|---|---| | ReadUncommitted | Yes | Yes | Yes | Rarely — dirty reads | | ReadCommitted | No | Yes | Yes | Default in SQL Server | | RepeatableRead | No | No | Yes | Audit reads, financial | | Serializable | No | No | No | Critical sections only | | Snapshot | No | No | No | High concurrency reads |

SQL Server's default is ReadCommitted. PostgreSQL defaults to ReadCommitted too. For most web APIs, the default is correct.

C#
// Example: Snapshot isolation for read-heavy reporting
await using var transaction = await _db.Database.BeginTransactionAsync(
    System.Data.IsolationLevel.Snapshot); // requires ALLOW_SNAPSHOT_ISOLATION = ON

var report = await _db.Orders
    .Where(o => o.CreatedAt >= DateTime.UtcNow.AddDays(-30))
    .GroupBy(o => o.Status)
    .Select(g => new { Status = g.Key, Count = g.Count() })
    .ToListAsync();

await transaction.CommitAsync();

The Unit of Work Pattern

EF Core's DbContext is the Unit of Work. It tracks all changes in memory and flushes them in a single transaction on SaveChanges. You rarely need a custom UoW wrapper on top of EF.

Where it adds value: when you want to abstract the persistence boundary in a testable way.

C#
public interface IUnitOfWork : IAsyncDisposable
{
    IRepository<Order> Orders { get; }
    IRepository<Payment> Payments { get; }
    Task<int> SaveChangesAsync(CancellationToken ct = default);
    Task BeginTransactionAsync(CancellationToken ct = default);
    Task CommitAsync(CancellationToken ct = default);
    Task RollbackAsync(CancellationToken ct = default);
}

public class EfUnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _db;
    private IDbContextTransaction? _transaction;

    public EfUnitOfWork(AppDbContext db) => _db = db;

    public IRepository<Order>   Orders   => new EfRepository<Order>(_db);
    public IRepository<Payment> Payments => new EfRepository<Payment>(_db);

    public Task<int> SaveChangesAsync(CancellationToken ct = default)
        => _db.SaveChangesAsync(ct);

    public async Task BeginTransactionAsync(CancellationToken ct = default)
        => _transaction = await _db.Database.BeginTransactionAsync(ct);

    public async Task CommitAsync(CancellationToken ct = default)
    {
        if (_transaction is null) throw new InvalidOperationException("No active transaction.");
        await _transaction.CommitAsync(ct);
    }

    public async Task RollbackAsync(CancellationToken ct = default)
    {
        if (_transaction is not null)
            await _transaction.RollbackAsync(ct);
    }

    public async ValueTask DisposeAsync()
    {
        if (_transaction is not null) await _transaction.DisposeAsync();
        await _db.DisposeAsync();
    }
}

Register it:

C#
builder.Services.AddScoped<IUnitOfWork, EfUnitOfWork>();

Usage in a command handler:

C#
public async Task Handle(PlaceOrderCommand cmd, CancellationToken ct)
{
    await _uow.BeginTransactionAsync(ct);
    try
    {
        var order = Order.Create(cmd.ProductId, cmd.Quantity);
        await _uow.Orders.AddAsync(order, ct);
        await _uow.SaveChangesAsync(ct);

        var payment = Payment.For(order);
        await _uow.Payments.AddAsync(payment, ct);
        await _uow.SaveChangesAsync(ct);

        await _uow.CommitAsync(ct);
    }
    catch
    {
        await _uow.RollbackAsync(ct);
        throw;
    }
}

Savepoints

SQL Server and PostgreSQL support savepoints — partial rollbacks within a transaction:

C#
await using var transaction = await _db.Database.BeginTransactionAsync();

_db.Orders.Add(order);
await _db.SaveChangesAsync();

await transaction.CreateSavepointAsync("after_order");

try
{
    // Risky operation
    await SendNotificationEmailAsync(order);
}
catch (EmailException)
{
    // Roll back only the notification, keep the order
    await transaction.RollbackToSavepointAsync("after_order");
}

await transaction.CommitAsync(); // order is committed, email attempt is not

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.