Skip to main content

EF Core transaction enlistment

Relayer.Outbox.EntityFrameworkCore implements transaction enlistment for the Npgsql EF Core provider. It buffers serialized sends, copies borrowed memory before each send completes, and writes one batched PostgreSQL INSERT immediately before commit. The insert uses the same connection and transaction as the business data. No SaveChanges override or outbox entity mapping is required.

This persists rows only. Register the PostgreSQL durable store separately for reads, claims and backlog health queries. A built-in dispatcher, inbox, lane leases, cleanup and outbox tracing remain planned. No broker send occurs when an outbox transaction commits. There are no published NuGet packages yet; use project references from a repository checkout.

Registration​

Create the PostgreSQL schema first. Register ordinary scoped DbContexts with the same scoped service provider used by ISender:

using Microsoft.EntityFrameworkCore;
using Relayer.Outbox.EntityFrameworkCore;

services.AddRelayerEntityFrameworkCoreOutbox();
services.AddDbContext<OrdersDbContext>((provider, options) =>
options.UseNpgsql(connectionString).AddRelayerOutbox(provider));

Keep your normal AddRelayer message and route registrations. Register these interceptors on each participating DbContext. Context pooling, concurrent use of one service scope and ambient TransactionScope enlistment are not supported. Other EF database providers fail on first use; SQL Server and MySQL schemas are still planned.

The defaults are schema public, table prefix relayer_, and 32 lanes. Set immutable options per context:

options.UseNpgsql(connectionString).AddRelayerOutbox(provider,
new EntityFrameworkCoreOutboxOptions
{
Schema = "messaging",
TablePrefix = "orders_",
Lanes = 32
});

Alternatively, services.AddRelayerEntityFrameworkCoreOutbox(configuration.GetSection("Outbox")) binds immutable defaults through the Options pattern. IOptionsSnapshot<EntityFrameworkCoreOutboxOptions> keeps a scope's settings stable; configuration changes apply to newly created scopes. Invalid identifiers, nonpositive lane counts and malformed numeric values fail validation. A per-context options argument overrides those defaults. Always match the deployed schema and prefix. Drain the outbox before changing lanes. LeaseDuration and MaxDispatchAttempts belong to the planned dispatcher and do not affect enlistment.

Save without an explicit transaction​

Select the context before sending, so Relayer knows which future save owns the messages:

using (db.EnlistRelayerOutbox())
{
db.Orders.Add(order);
await sender.SendAsync(new OrderPlaced(order.Id), cancellationToken);
await db.SaveChangesAsync(cancellationToken);
}

SaveChangesAsync creates an owned transaction when rows are buffered, even for a single business command or an outbox-only save. It commits business changes and outgoing rows together. Synchronous SaveChanges works too. Disposing the selection before saving discards unsaved messages. A second save within the selection starts a new unit. Disposing the selection never commits anything.

Explicit transactions​

Starting an EF transaction activates automatic enlistment when it is the scope's only active context:

await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
await sender.SendAsync(new OrderPlaced(order.Id), cancellationToken);
db.Orders.Add(order);
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);

Sends made after the final save still join the same batch at commit. Several saves do not cause several outbox inserts. SendViaOutboxAsync requires an active transaction or context selection; SendNowAsync bypasses enlistment. Without either, SendAsync sends directly to the transport.

Commit through EF's IDbContextTransaction, including the wrapper returned by UseTransactionAsync. Calling Commit directly on the underlying DbTransaction bypasses EF interceptors and is unsupported. Use EF's rollback and disposal APIs too. Cancellation or insertion failure prevents commit; disposing or rolling back a transaction discards its buffered rows. Native callbacks cannot be persisted and fail before buffering.

After a failed save, commit, or manual savepoint rollback, roll back the complete explicit transaction and retry the whole unit with a fresh DbContext. Relayer rejects commit of an invalidated unit. For an implicit save, insertion failure rolls back business data even if EF has already accepted tracked changes; reusing that change tracker is not a recovery strategy. Configure EF execution strategies around the whole explicit transaction, rather than retrying a final save alone.

Retrying execution strategies​

When Npgsql uses EnableRetryOnFailure(), an implicit save with buffered messages fails before opening a transaction, with a Relayer error explaining the required explicit transaction. Wrap the entire unit in EF's execution strategy and create a fresh DI scope and DbContext on each attempt:

await strategyContext.Database.CreateExecutionStrategy().ExecuteAsync(async () =>
{
await using var scope = serviceScopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
db.Orders.Add(new Order { Id = orderId });
await sender.SendAsync(new OrderPlaced(orderId), cancellationToken);
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
});

Synchronous Execute, SaveChanges and transaction methods follow the same pattern. Do not carry tracked entities or buffered messages from a failed attempt into the next attempt. A connection failure during commit can leave the outcome unknown; retries still require application-level idempotency.

Several DbContexts​

Each context owns its own messages. Saving another context does not consume that buffer. When two contexts have active transactions, wrap each send group in context.EnlistRelayerOutbox() to select the owner; ambiguous automatic sends fail. Nested selections restore the previous owner when disposed in reverse creation order. Dispose selections before their contexts.

Contexts may share a native connection and transaction with EF's UseTransactionAsync. Configure them with the same scoped provider, schema, prefix and lane count. Select each owner while sending and commit through EF. Relayer combines their rows into one insert for that native transaction. Disposing a joined context or its borrowed transaction wrapper leaves its buffered messages attached to the live native transaction. Automatic sends then select the remaining active context. Commit or rollback through the owner ends the entire shared unit. Complete that transaction before reusing a detached context for another unit; Relayer rejects reuse that could discard its pending messages.

Stored representation​

IOutboxEnlistment.EnlistAsync(OutboxRecord, CancellationToken) receives the original UUIDv7 ID, stable wire type and creation time directly from the send pipeline. This also works with raw envelopes and custom native header names; Relayer does not infer identity by scanning headers. The serialized body, all headers, ordering key and delivery time survive buffer reuse. New sends have zero attempts and no dispatch error; callers cannot supply a lane. Tenant ID is preserved when supplied through the low-level record contract; automatic tenant resolution remains planned.

Header codec version 1 uses little-endian Int32 version and count, followed by name/value pairs, each with an Int32 byte length and UTF-8 bytes. Header order and duplicate names are preserved. Ordered rows use FNV-1a over the UTF-8 ordering key, modulo the configured lane count; unordered rows have a null lane. The writer passes reusable column lists to one unnest insert, with a fixed parameter count for large batches.

The enlistment pipeline uses no reflection or runtime code generation. EF Core and its provider still require their own supported compiled-model/query setup for Native AOT; the database transaction tests run under the JIT and do not establish end-to-end EF Native AOT support.