Skip to content

OutboxPattern

blehnen edited this page Nov 10, 2026 · 2 revisions

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. This is the right default for fire-and-forget enqueue. But it prevents 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 and runs all queue INSERTs inside it. It 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 that CreateProducer<T> returns does not implement IRelationalProducerQueue<T>. The is capability cast returns false. The producer does not throw NotSupportedException. A misconfigured caller fails at the cast, not 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 also rolls back the queue row, so the queue delivers no message. This is the outbox 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 in the same way.

Retry

On the caller-transaction path, the producer bypasses its internal Polly retry decorator chain. The caller owns the transaction lifecycle and must own retry semantics. After a transient failure in the middle of a transaction, the connection may be in an inconsistent state. A retry inside the transport would run the INSERT again on that connection.

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

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 same 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