Skip to content

OutboxPattern

Brian Lehnen edited this page May 16, 2026 · 1 revision

Outbox Pattern

The transactional outbox pattern guarantees that a queue message and a business database write either both commit or both roll back. The default DotNetWorkQueue producer opens and manages its own connection and transaction, which is the right default for fire-and-forget enqueue but precludes the outbox pattern because the queue INSERT is not part of the caller's business transaction.

IRelationalProducerQueue<T> is the opt-in surface that fixes this. It is a derived interface that accepts a caller-supplied DbTransaction, runs all queue INSERTs inside it, and never commits, rolls back, or disposes the caller's resources.

Supported transports
Transport Supported
SQL Server Yes
PostgreSQL Yes
SQLite No
Redis No
Memory No
LiteDb No

On unsupported transports the producer instance returned by CreateProducer<T> does not implement IRelationalProducerQueue<T>. The is capability cast returns false; no NotSupportedException is thrown. A misconfigured caller fails at the cast rather than at the first Send.

Prerequisites

Queue tables must exist in your database before the first Send on the caller-transaction path. Run CreateQueue() once at deployment time as part of your normal database migration. The outbox path does not auto-create tables.

Usage

[Capability cast]

using DotNetWorkQueue;
using DotNetWorkQueue.Configuration;
using DotNetWorkQueue.Transport.RelationalDatabase;
using DotNetWorkQueue.Transport.SqlServer;

var conn = new QueueConnection(queueName, connectionString);

using var producerContainer = new QueueContainer<SqlServerMessageQueueInit>();
using var producer = producerContainer.CreateProducer<OrderCreatedEvent>(conn);

if (producer is not IRelationalProducerQueue<OrderCreatedEvent> outbox)
    throw new InvalidOperationException("Transport does not support the outbox pattern.");

[Send inside the caller's transaction]

using var sqlConn = new SqlConnection(connectionString);
sqlConn.Open();

using var transaction = sqlConn.BeginTransaction();

// Caller's business write
using (var cmd = sqlConn.CreateCommand())
{
    cmd.Transaction = transaction;
    cmd.CommandText = "INSERT INTO Orders (OrderId, Status) VALUES (@id, @status)";
    cmd.Parameters.AddWithValue("@id", 42);
    cmd.Parameters.AddWithValue("@status", "Pending");
    cmd.ExecuteNonQuery();
}

// Outbox write: enqueue inside the same transaction
var result = outbox.Send(
    new OrderCreatedEvent { OrderId = 42, Status = "Pending" },
    transaction);

if (result.HasError)
    throw new InvalidOperationException($"Enqueue failed: {result.SendingException}");

transaction.Commit();

A rollback after a successful Send rolls back the queue row too, so no message is delivered. That is the guarantee.

IRelationalProducerQueue<T> also has SendAsync overloads with matching signatures.

[PostgreSQL]

The same pattern works with NpgsqlConnection, NpgsqlTransaction, and PostgreSqlMessageQueueInit as the transport initializer. The capability cast succeeds identically.

Retry

The producer's internal Polly retry decorator chain is bypassed on the caller-transaction path. The caller owns the transaction lifecycle and must own retry semantics. A retry inside the transport would re-execute the INSERT while the connection may be in an inconsistent state after a transient failure mid-transaction.

To retry the whole business operation, wrap your business writes and the Send call in your own retry policy (for example Polly) and re-call Send from scratch on each attempt:

Retry policy
  └─ open connection + begin transaction
  └─ business INSERT
  └─ outbox.Send(msg, transaction)
  └─ transaction.Commit()
Database-name validation

At the start of every caller-transaction Send, the validator checks that the connection's reported database matches the queue's configured catalog. The comparison is byte-for-byte ordinal with no case folding on either transport. Configure your connection string's Database= value with the exact case as the catalog name on the server. If the names do not match, Send throws InvalidOperationException before writing any data.

Full reference

See docs/outbox-pattern.md in the main repository for the complete lifecycle contract, database-name extractor details per transport, schema deployment guidance, and the authoritative thread-safety notes.

Clone this wiki locally