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). Two behaviors make the boundary forgiving:

  • Commit flushes the change tracker — the scope calls SaveChangesAsync before committing, so a handler that mutated the DbContext but 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 IHandlerSender on the same scope, the inner BeginAsync sees 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 / 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 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 — an UPDATE that matches zero rows passes on InMemory but throws DbUpdateConcurrencyException on 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.

On this page