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). 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
AddInMemoryEventBus<TContext>() attaches to your context automatically — so a plain
AddDbContext<TContext>() is all you write:
services.AddInMemoryEventBus<AppDbContext>();
services.AddDbContext<AppDbContext>(o => o.UseNpgsql(connectionString));The durable outbox tier commit-gates
through its own SaveChanges interceptor instead.
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.