Repository navigation
OutboxPattern
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.
| 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.
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.
[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.
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()
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.
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.
For any issues please use the GitHub issues