Skip to content

// Resilience & Messaging · 02

Transactional Outbox

DB writeleads tosame txRelayleads toBroker

Saves the event in the same breath as the data, so it can't get lost.

How it fits together

Transactional Outbox structureOrder servicePlaceOrder()one local commitOrdersOrderId, TotalOutbox tablemessage idsent = falseMessage brokerOrderPlaced topicOutbox relaybackground workerevery 1s, batch 100Consumersdedupe on message id1 same tx1 same tx2 read unsent3 publish4 deliver
createsuses / callspublishes / notifies· numbered steps replay in order
  • Order service creates Orders (1 same tx)
  • Order service creates Outbox table (1 same tx)
  • Outbox relay uses / calls Outbox table (2 read unsent)
  • Outbox relay publishes / notifies Message broker (3 publish)
  • Message broker publishes / notifies Consumers (4 deliver)

Spot it

  • Order saved but no OrderPlaced event sent
  • Event published for a rolled-back change
  • Data and messages drift after a crash

It costs

Publishing becomes asynchronous and at-least-once, and the outbox table needs cleanup.

In code

public sealed class OutboxStore(AppDb db, IMessageBroker broker){    public async Task PlaceAsync(Order order, CancellationToken ct)    {        db.Orders.Add(order);        db.Outbox.Add(OutboxMessage.For("OrderPlaced", order));        await db.SaveChangesAsync(ct); // one commit: both rows or neither    }​    public async Task RelayBatchAsync(CancellationToken ct)    {        var batch = await db.Outbox            .Where(m => m.SentAt == null)            .OrderBy(m => m.Id)            .Take(100)            .ToListAsync(ct);        foreach (var message in batch)        {            await broker.PublishAsync(message.Id, message.Payload, ct);            message.SentAt = DateTimeOffset.UtcNow;        }​        await db.SaveChangesAsync(ct);    }}
Go deeperThe full explanation and what to ask for · 1 min readExpand

Saving to the database and then publishing to the broker is a dual write: either can fail after the other succeeded. The outbox turns it into one local transaction plus a relay that retries until the broker acknowledges.

The relay can crash after publishing but before marking the row sent, so the same event goes out again. An outbox without idempotent consumers just moves the duplicate problem downstream. Give every event a stable id and dedupe on it.

With several relay instances, claim rows with SKIP LOCKED (PostgreSQL) or READPAST (SQL Server) and expect ordering per aggregate, not globally. Change data capture is an alternative relay that tails the transaction log instead of polling. The inbox is the mirror pattern on the consumer side.

Ask for it

  • dual write
  • outbox table
  • relay / dispatcher
  • at-least-once
  • change data capture
  • inbox

Replace the direct broker publish in PlaceOrder with an outbox: add an outbox row in the same database commit, then a background worker relays unsent rows every 1s in batches of 100, marks them sent, and uses the row id as the message id for duplicate detection.