-
Notifications
You must be signed in to change notification settings - Fork 16
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, 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.
| 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.
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 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.
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()
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.
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