Skip to content

EF Core

[Transactional] delegates to an IUnitOfWork you provide. Here is the EF Core implementation to use.

IUnitOfWork

public sealed class EfCoreUnitOfWork(DbContext context) : IUnitOfWork
{
public async ValueTask BeginTransactionAsync(CancellationToken ct)
{
// Already inside one (e.g. an execution-strategy retry)? Join it.
if (context.Database.CurrentTransaction is not null) return;
await context.Database.BeginTransactionAsync(ct);
}
public async ValueTask SaveChangesAsync(CancellationToken ct)
=> await context.SaveChangesAsync(ct);
public async ValueTask CommitAsync(CancellationToken ct)
{
if (context.Database.CurrentTransaction is null) return;
await context.Database.CurrentTransaction.CommitAsync(ct);
}
public async ValueTask RollbackAsync(CancellationToken ct)
{
if (context.Database.CurrentTransaction is null) return;
await context.Database.CurrentTransaction.RollbackAsync(ct);
}
public ValueTask CreateSavepointAsync(string name, CancellationToken ct)
=> new(context.Database.CurrentTransaction?.CreateSavepointAsync(name, ct) ?? Task.CompletedTask);
public ValueTask RollbackToSavepointAsync(string name, CancellationToken ct)
=> new(context.Database.CurrentTransaction?.RollbackToSavepointAsync(name, ct) ?? Task.CompletedTask);
}

Register

services.AddDbContext<AppDbContext>(o => o.UseNpgsql(connectionString));
services.AddScoped<IUnitOfWork, EfCoreUnitOfWork>();
services.AddMediantTransactions(); // TransactionBehavior + IPostCommitTaskQueue

What the behavior does for you

Nested transactions. When a [Transactional] handler dispatches another [Transactional] command, the behavior detects the outer scope (via AsyncLocal<bool>) and only the outermost one begins and commits. The inner handler participates in the same transaction.

Auto SaveChanges. SaveChangesAsync() is called before CommitAsync(), so the change tracker is flushed even if the handler forgot. Write your handler without it:

public class CreateOrderHandler(AppDbContext db, IPostCommitTaskQueue postCommit)
: ICommandHandler<CreateOrder>
{
public async ValueTask<Result> Handle(CreateOrder cmd, CancellationToken ct)
{
db.Orders.Add(new Order { /* ... */ });
// No SaveChangesAsync here — TransactionBehavior does it.
postCommit.Enqueue(ct => emailService.SendConfirmationAsync(cmd.Email, ct));
return Result.Success();
}
}

Post-commit tasks. IPostCommitTaskQueue runs fire-and-forget work only once the commit succeeds — and drops it if the transaction rolls back. Use it for the email that must not be sent when the order wasn’t saved. For work that must survive a crash, use the outbox instead.

Transient retries (Npgsql, SQL Server)

Providers with a retrying execution strategy need explicit transactions wrapped in that strategy:

public async ValueTask BeginTransactionAsync(CancellationToken ct)
{
if (context.Database.CurrentTransaction is not null) return;
var strategy = context.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async token =>
{
await context.Database.BeginTransactionAsync(token);
}, ct);
}

Durable outbox and audit stores

The Mediant.EntityFrameworkCore package provides EF Core-backed stores so events and audit entries survive a restart.

Terminal window
dotnet add package Mediant.EntityFrameworkCore
builder.Services.AddMediantOutbox(); // processor
builder.Services.AddMediantEfCoreOutboxStore<AppDbContext>(); // durable store
builder.Services.AddMediantEfCoreAuditStore<AppDbContext>(); // durable audit trail

Without the second line, AddMediantOutbox() uses an in-memory store — correct for tests, unsafe for production.