The transactional outbox pattern
Saving to the database and publishing a message are two separate operations, and one of them will eventually fail. The outbox pattern makes them succeed or fail together.
Here's a handler that looks fine and isn't:
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
await sender.SendMessageAsync(new ServiceBusMessage(BinaryData.FromObjectAsJson(
new OrderPlaced(order.Id, order.CustomerId, order.Total))), ct);
Two separate systems, two separate operations. If the process crashes between them, or Service Bus is briefly unavailable, the order exists but nobody downstream ever hears about it. Swap the order of the two calls and you get the opposite bug: a message about an order that was never saved.
A distributed transaction across your database and the message broker isn't available, and wouldn't be a good idea if it were.
The outbox: one transaction, then publish
Instead of publishing directly, write the message to an outbox table in the same database, in the same transaction as the business data:
await using var tx = await db.Database.BeginTransactionAsync(ct);
db.Orders.Add(order);
db.Outbox.Add(OutboxMessage.From(new OrderPlaced(order.Id, order.CustomerId, order.Total)));
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
Either both rows are saved or neither is. The message is now guaranteed to exist whenever the order does.
A separate background process reads unsent outbox rows, publishes them and marks them as sent:
public class OutboxDispatcher(IServiceScopeFactory scopes, ServiceBusSender sender) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await using var scope = scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OrdersDb>();
var pending = await db.Outbox
.Where(m => m.SentAt == null)
.OrderBy(m => m.CreatedAt)
.Take(50)
.ToListAsync(stoppingToken);
foreach (var message in pending)
{
await sender.SendMessageAsync(new ServiceBusMessage(message.Body)
{
MessageId = message.Id.ToString(),
Subject = message.Type
}, stoppingToken);
message.SentAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(stoppingToken);
}
}
}
}
The trade-off: at-least-once
If the dispatcher crashes after sending but before marking the row as sent, it sends the message again on the next run. The outbox guarantees a message is never lost, not that it's sent exactly once. That's the right trade, but it means consumers must handle duplicates. Setting MessageId from the outbox row's ID also lets Service Bus duplicate detection drop most of these repeats.
Details worth getting right
- Running several instances? Make sure two dispatchers don't pick up the same rows. A single dispatcher instance, or row locking such as
UPDLOCK, READPASThints in SQL Server, both work. - Clean up. Delete or archive sent rows after a few days, or the table grows forever.
- Order matters? Dispatch in creation order and stop on the first failure, rather than skipping ahead.
Or use a library
MassTransit, NServiceBus and Wolverine all include an outbox. If you already use one of them, turn it on rather than writing your own.
Takeaway
Never save data and publish a message as two independent steps. Write the message to an outbox table in the same transaction, publish it from a background process, and make your consumers idempotent because the outbox delivers at least once.