Persistence & transactions
How Elarion's EF Core stores and the event buses participate in the caller's database transaction — what commits and rolls back together, and what is delivered after commit.
Elarion's data stores are designed to compose with your unit of work. A handler can
open a transaction on its DbContext, call one or more framework stores, and have their
writes commit — or roll back — atomically with its own business data. Nothing opens a
side connection that escapes your transaction.
Every EF Core store acts on the DbContext you inject, so it rides that context's
current transaction. You do not pass a transaction around; you share the DbContext.
See ADR-0015.
The contract
Wrap framework writes in your own transaction and they are part of it:
[Handler("billing.archive")]
public sealed class ArchiveInvoice(
BillingDbContext db,
ISettingsManager settings,
IBlobStore blobs)
: IHandler<ArchiveInvoice.Command, Result<Unit>> {
public async ValueTask<Result<Unit>> HandleAsync(Command command, CancellationToken ct) {
await using var tx = await db.Database.BeginTransactionAsync(ct);
db.Invoices.Remove(await db.Invoices.FindAsync([command.Id], ct));
await db.SaveChangesAsync(ct);
// Both of these run on the SAME connection + transaction as the delete above.
await settings.SetAsync($"invoice:{command.Id}:archived", "true", ct);
await blobs.SaveAsync(ArchiveRequest(command.Id), command.Pdf, ct);
await tx.CommitAsync(ct); // all three commit together
return Result.Success(); // a throw / RollbackAsync would discard all three
}
}Let the framework own the boundary
Opening the transaction by hand is optional. TransactionDecorator wraps a handler in a single
unit-of-work transaction that commits on a successful Result and rolls back otherwise — so the
handler body writes as if the boundary were free:
[Handler("billing.archive")]
public sealed class ArchiveInvoice(
BillingDbContext db,
ISettingsManager settings,
IBlobStore blobs)
: IHandler<ArchiveInvoice.Command, Result<Unit>> {
// No BeginTransactionAsync / CommitAsync — the decorator owns the boundary.
public async ValueTask<Result<Unit>> HandleAsync(Command command, CancellationToken ct) {
db.Invoices.Remove(await db.Invoices.FindAsync([command.Id], ct));
await db.SaveChangesAsync(ct);
await settings.SetAsync($"invoice:{command.Id}:archived", "true", ct);
await blobs.SaveAsync(ArchiveRequest(command.Id), command.Pdf, ct);
return Result.Success(); // commit; a failed Result or throw rolls all three back
}
}The decorator attaches only where a new unit of work belongs — its compile-time
AppliesTo predicate matches commands and integration-event handlers and skips queries,
domain-event handlers (they run inline inside the publisher's transaction), and [Idempotent]
handlers (they own the boundary themselves, so a handler is never wrapped in two nested
transactions).
Under the hood it composes the EF-free IUnitOfWork/IUnitOfWorkScope seam
(Elarion.Abstractions.Pipeline). EfUnitOfWork<TDbContext> implements it over
DbContext.Database.BeginTransactionAsync, with savepoint support and — on PostgreSQL — a
SET LOCAL lock_timeout derived from UnitOfWorkOptions.LockTimeout (a blocked statement
fast-fails instead of waiting; other providers ignore it). Two behaviors make the boundary
forgiving:
- Commit flushes the change tracker — the scope calls
SaveChangesAsyncbefore committing, so a handler that mutated theDbContextbut forgot to save still persists its writes atomically with the transaction (a no-op when it already saved). - A nested transactional handler joins with a savepoint — when a transactional command
invokes another transactional command through
IHandlerSenderon the same scope, the innerBeginAsyncsees the ambient transaction and creates a savepoint instead of opening a second physical transaction (which the provider would reject). The inner commit releases the savepoint, the inner rollback discards only the inner writes, and the outer scope still owns the real commit.
Wire it with AddElarionUnitOfWork<TDbContext>(), which replaces the default in-memory unit of
work:
services.AddElarionUnitOfWork<AppDbContext>();Idempotency composes this same IUnitOfWork boundary — the
[Idempotent] handler claims its key inside the transaction it owns, which is why
TransactionDecorator steps aside for it.
What enlists, and how
| Store / bus | Technique | Participates in your transaction |
|---|---|---|
Framework command boundary (TransactionDecorator over IUnitOfWork) | EfUnitOfWork<TDbContext> opens the transaction; commits on success, rolls back otherwise | Yes — it is your transaction (commands & integration events) |
Settings (EfCoreSettingsStore) | ExecuteUpdate/ExecuteDelete + raw INSERT on the injected context | Yes |
Resource grants (EfCoreResourceGrantStore) | change tracker + ExecuteDelete | Yes |
Outbox capture (EfCoreOutboxStore.Append) | change tracker, committed with your data | Yes |
Blob store (PostgreSqlBlobStore) | metadata via change tracker; bytea content via raw Npgsql explicitly enlisted in Database.CurrentTransaction | Yes — metadata + content together |
Domain events (IDomainEventBus) | dispatched inline in your scope | Yes — runs inside your transaction |
| Integration events (in-memory) | buffered, flushed by EF interceptors after commit, discarded on rollback | Commit-gated (see below) |
Outbox delivery (OutboxDeliveryService) | runs on its own scope after commit | No — by design, post-commit |
The blob store opens its own transaction only when you have not started one, so a
standalone SaveAsync is still atomic. Deleting a blob removes its content row in the
same transaction via an ON DELETE CASCADE foreign key.
After-commit delivery is deliberate
Two things intentionally run after your transaction commits rather than inside it (see ADR-0001):
- Integration events (
IIntegrationEventBus) are recorded in your unit of work and delivered after commit — never on rollback. This is the cross-boundary notification plane. - Outbox delivery polls and dispatches durable messages on isolated scopes, so a delivery failure never fails your command.
The in-memory integration tier is commit-gated by EF Core interceptors that
AddElarionInMemoryEventBus<TContext>() attaches to your context automatically — so a plain
AddDbContext<TContext>() is all you write:
services.AddElarionInMemoryEventBus<AppDbContext>();
services.AddDbContext<AppDbContext>(o => o.UseNpgsql(connectionString));The durable outbox tier commit-gates
through its own SaveChanges interceptor instead.
Testing persistence
Test persistence against a real PostgreSQL — a disposable Testcontainers instance — not the EF Core InMemory provider. InMemory is LINQ over dictionaries, not a relational database: it silently diverges from Postgres in exactly the places a persistence test exists to cover, so a green InMemory test is false confidence. It does not:
- enforce
SaveChanges' affected-rows check — anUPDATEthat matches zero rows passes on InMemory but throwsDbUpdateConcurrencyExceptionon Postgres. This is the trap behind client-assigned keys (SQLite skips the check too); - run real transactions, savepoints,
lock_timeout, or concurrency tokens; - execute provider SQL — keyset paging's row-value seek,
COLLATE "C"ordering,ExecuteUpdate, raw SQL,ON CONFLICT, or unique-index violations; - apply your migrations or database constraints.
Use a Docker-gated fixture that skips (never fails) when Docker is absent, so the suite still runs on a machine without it:
public sealed class PostgresFixture : IAsyncLifetime {
private PostgreSqlContainer? _container;
public bool IsAvailable { get; private set; }
public string SkipReason { get; private set; } = "";
public string ConnectionString { get; private set; } = "";
public async ValueTask InitializeAsync() {
try {
var container = new PostgreSqlBuilder("postgres:17-alpine").Build();
await container.StartAsync(); // validates the Docker endpoint
_container = container;
ConnectionString = container.GetConnectionString();
await using var db = CreateContext();
await db.Database.EnsureCreatedAsync(); // or MigrateAsync() to exercise migrations
IsAvailable = true;
}
catch (Exception ex) {
SkipReason = $"Docker required: {ex.Message}"; // skip, don't fail
}
}
public AppDbContext CreateContext() =>
new(new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(ConnectionString).Options);
public async ValueTask DisposeAsync() {
if (_container is not null) await _container.DisposeAsync();
}
}public sealed class InvoiceTests(PostgresFixture fixture) : IClassFixture<PostgresFixture> {
[Fact]
public async Task ReplacingLineItems_Persists() {
Assert.SkipUnless(fixture.IsAvailable, fixture.SkipReason);
await using var db = fixture.CreateContext(); // each test owns its own context
// … exercise the handler against real Postgres, then assert on a fresh context …
}
}A test that only inspects the model (mappings, keys, indexes) needs no container: build the context
with UseNpgsql(...) and a throwaway connection string and read context.Model — the provider shapes the
model without opening a connection. It is still the real provider, not InMemory.
The one legitimate use of InMemory is a demo that must run without Docker — the quickstart uses it so you can try Elarion in five minutes. Anything that asserts database behavior uses a real database; the framework's own persistence tests are all Testcontainers-backed.
Settings
Runtime-changeable, key/value settings with a swappable store, in-process change watching, and an AOT-clean typed accessor — global and per-user.
Variable substitution
Spring-style ${key:-default} placeholders resolved from a pluggable source — a general building block reused across Elarion subsystems, not tied to any one feature.