PostgreSQL transactions and Dapper
Relayer.Outbox.PostgreSql registers a scoped PostgreSqlOutbox and IOutboxEnlistment.
Messages sent in its active transaction are copied into owned buffers. Commit or CommitAsync writes
all messages in one PostgreSQL insert, then commits the native transaction containing your business changes.
No broker send occurs on this path. ADO.NET commit-triggered dispatch remains planned.
Ordered PostgreSQL dispatch and lane leases are available through separate registration.
Inbox deduplication is available through the EF Core consumer boundary.
Use EF Core enlistment instead when EF owns the transaction.
Registration
Deploy the shared schema first. Keep schema, prefix and lane count identical across writers and readers of the same tables. Changing the lane count requires draining existing rows first.
using Relayer.Outbox.PostgreSql;
services.AddRelayerPostgreSqlOutbox(new PostgreSqlOutboxOptions
{
Schema = "messaging",
TablePrefix = "orders_",
Lanes = 32
});
Resolve PostgreSqlOutbox and ISender from the same DI scope. Only one IOutboxEnlistment integration can
own sends in a scope; do not register this alongside EF Core enlistment. Each scope supports one transaction
at a time and must not be used concurrently. Registration does not create tables, register a read store,
start workers or take ownership of a data source.
PostgreSqlOutboxOptions has init-only properties. For configuration binding and startup validation, pass an
IConfiguration section to services.AddRelayerPostgreSqlOutbox(configuration.GetSection("Outbox")).
New scopes read updated configuration through IOptionsSnapshot<PostgreSqlOutboxOptions>.
LeaseDuration and MaxDispatchAttempts do not configure enqueue; enqueue uses schema, prefix and lanes. Configure dispatch and recovery through their separate worker options.
To expose durable reads and backlog health, register the keyed store separately.
ADO.NET and Dapper
using Dapper;
using Npgsql;
using Relayer;
using Relayer.Outbox.PostgreSql;
// dataSource belongs to the application. outbox and sender belong to this DI scope.
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
await using var transaction = await outbox.BeginTransactionAsync(connection,
cancellationToken: cancellationToken);
await transaction.Connection!.ExecuteAsync(new CommandDefinition(
"INSERT INTO orders (id) VALUES (@Id)", new { order.Id }, transaction,
cancellationToken: cancellationToken));
await sender.SendAsync(new OrderPlaced(order.Id), cancellationToken: cancellationToken);
await transaction.CommitAsync(cancellationToken);
The result is a DbTransaction subclass. Use transaction.Connection for business commands. Npgsql's
NpgsqlCommand.Transaction accepts only a native NpgsqlTransaction; assigning this wrapper to a command
created from the original NpgsqlConnection fails. The returned connection view creates commands that unwrap
the transaction for Npgsql. It supports ordinary ADO.NET commands and Dapper's synchronous and asynchronous
commands, scalar results and readers, including PostgreSQL array parameters such as WHERE id = ANY(@Ids).
Dapper is an application dependency, not a Relayer package dependency. DbCommand.Connection returns the
underlying NpgsqlConnection so Dapper retains native provider detection. This does not change command
creation: use transaction.Connection to create commands that can accept the wrapper transaction. Keep
connection and transaction lifecycle operations on the outbox wrapper even when inspecting a command's native connection.
You can also let the transaction own its opened connection:
await using var transaction = await outbox.BeginTransactionAsync(dataSource,
cancellationToken: cancellationToken);
// Use transaction.Connection for the business commands, then send and commit as above.
The connection overload requires an already open connection and leaves it open after completion.
The data source overload closes its opened connection on commit, rollback, failure or disposal, without
disposing the application-owned data source. Disposing the borrowed connection view does not dispose the native
connection. Both overloads accept an optional native IsolationLevel unchanged.
Durability and recovery
SendAsyncenlists while this transaction is active. Outside it,SendAsyncuses the normal send path.SendViaOutboxAsyncrequires an active transaction;SendNowAsyncdeliberately bypasses the outbox.- Serialized bodies, header names, duplicate headers, header bytes and ordering keys are copied before
EnlistAsynccompletes. The original UUIDv7 ID, stable wire type, creation time, tenant andDeliverAtpersist. Ordered rows use the shared FNV-1a lane calculation; unordered rows have a null lane. Large transactions use a fixed number of array parameters rather than one parameter set per message. - Rollback, disposal of an uncommitted transaction and scope disposal discard the buffer and roll back business
work. A failed or cancelled commit ends the unit; never retry
Commiton that wrapper. Start a new transaction and retry the whole business unit. The scope can be reused after completion; disposal of an older wrapper cannot discard the next transaction's rows. - A lost connection or cancellation during native commit can leave the outcome unknown. Inspect durable business state using an application idempotency key before retrying; no client can undo an acknowledged server commit.
- Native broker callbacks are rejected before persistence because delegates cannot survive storage.
Headers and ordering remain available. Scheduled sends through
ISenderrequire a matching maintenance worker for unordered rows or ordered dispatcher for ordered rows; commit-only dispatch cannot deliver future rows. This database transaction does not enlist a broker's native transaction or change its delivery guarantees.
Commit, rollback and disposal must go through the returned transaction. Do not close the original connection,
issue SQL transaction-control commands, or obtain and complete a separate native transaction behind the wrapper.
Savepoints and nested transactions are unsupported and fail explicitly through the wrapper API: rolling back
business changes to a savepoint would also require rewinding the message buffer. CommandBehavior.CloseConnection
is rejected because it would close an active transaction. Use normal reader disposal and transaction disposal.
The buffers retain their peak capacity for reuse within the scope. Bound the size of business units to bound
memory usage. The default Npgsql data source includes array mappings; with NpgsqlSlimDataSourceBuilder, call
EnableArrays() before building the data source. Native AOT applications must use Npgsql's AOT-compatible
data source configuration and source-generated message serializers.