Elarion

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 / busTechniqueParticipates in your transaction
Framework command boundary (TransactionDecorator over IUnitOfWork)EfUnitOfWork<TDbContext> opens the transaction; commits on success, rolls back otherwiseYes — it is your transaction (commands & integration events)
Settings (EfCoreSettingsStore)ExecuteUpdate/ExecuteDelete + raw INSERT on the injected contextYes
Resource grants (EfCoreResourceGrantStore)change tracker + ExecuteDeleteYes
Outbox capture (EfCoreOutboxStore.Append)change tracker, committed with your dataYes
Blob store (PostgreSqlBlobStore)metadata via change tracker; bytea content via raw Npgsql explicitly enlisted in Database.CurrentTransactionYes — metadata + content together
Domain events (IDomainEventBus)dispatched inline in your scopeYes — runs inside your transaction
Integration events (in-memory)buffered, flushed by EF interceptors after commit, discarded on rollbackCommit-gated (see below)
Outbox delivery (OutboxDeliveryService)runs on its own scope after commitNo — 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.

On this page