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 + IPostCommitTaskQueueWhat 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.
dotnet add package Mediant.EntityFrameworkCorebuilder.Services.AddMediantOutbox(); // processorbuilder.Services.AddMediantEfCoreOutboxStore<AppDbContext>(); // durable storebuilder.Services.AddMediantEfCoreAuditStore<AppDbContext>(); // durable audit trailWithout the second line, AddMediantOutbox() uses an in-memory store — correct for tests, unsafe for
production.