Articles · Messaging & Reliability ·011

The Outbox Pattern

Runnable sample on GitHub

Sample: samples/011-outbox — outbox atomicity over real PostgreSQL. dotnet test (needs Docker).

Prerequisites: 003 — Domain Events, 005 — Event Sourcing Basics.

Overview

A command often needs to do two things: change its own state (save the order) and tell other services about it (publish OrderPlaced). Doing both reliably is the dual-write problem — and it has no safe solution if the two writes go to two systems (your database and a message broker). The outbox pattern solves it by making the “tell other services” write go to the same database as the state change, in the same transaction: you stage the integration event as a row in an outbox table, commit atomically, and a separate processor relays those rows to the broker afterwards. Relay’s IIntegrationEventBus.Publish stages the row; the TransactionBehavior commits it with everything else. This article is the staging half (the atomic write); article 014 is the relaying half.

Why this exists

Picture the obvious, broken approach: save the order to the database, then publish to RabbitMQ. Four failure modes, two of them silent and corrupting:

  1. DB commit succeeds, broker publish fails → state changed, nobody was told (lost event).
  2. Broker publish succeeds, DB commit fails → you announced an order that doesn’t exist (phantom event).
  3. The process crashes between the two → either of the above, non-deterministically.

You cannot wrap a database transaction and a broker publish in one atomic unit — they are different systems with no shared transaction (distributed transactions / 2PC are slow, brittle, and broadly deprecated). The outbox sidesteps the whole problem: there is only one write, to one system. The integration event becomes a row in relay_outbox_messages, written in the same transaction as the order. Either both commit or neither does — atomicity you already have. A background processor then reads committed outbox rows and publishes them to the broker, retrying until they land (article 014). The guarantee splits cleanly: exactly-once persistence (the atomic commit) plus at-least-once delivery (the retrying relay).

When to use this

Use the outbox whenever a state change must reliably trigger something in another service or system:

  • Order management → fulfillment: OrderPlaced must reach the warehouse, even if the warehouse (or the broker) is momentarily down.
  • Payments → ledger/notifications: PaymentCaptured must produce a ledger entry and a receipt email, and must not be lost or duplicated relative to the payment.
  • Inventory → search/analytics: StockAdjusted feeds downstream read stores; losing one corrupts them.
  • Any event that crosses a service boundary. Domain events (article 003) stay in-process and in-transaction; the moment an event must leave the service, it goes through the outbox.

The signal: “after we commit X, we must be sure that Y (in another system) eventually happens.” That “be sure, across a boundary” is precisely the outbox.

When not to use this

  • In-process reactions. If the reaction is within the same service (update a read model, write an audit row), that’s a domain event (article 003), handled in the same transaction directly — no outbox, no broker.
  • Fire-and-forget where loss is acceptable. Best-effort metrics, non-critical telemetry — if losing the occasional event is fine, the outbox’s durability is overhead.
  • Synchronous request/response. The outbox is for asynchronous, eventually-delivered events. If you need an immediate answer from another service, that’s an RPC/HTTP call, not an event.
  • No database transaction at all. The outbox rides the command’s transaction; a service with no transactional store (the DB-free samples) has nothing to stage onto.

The costs: an outbox table and a processor to run (article 014); at-least-once delivery (consumers must dedup — the inbox, article 013); a small publish latency (the relay runs slightly after commit); and the operational surface of a backlog to monitor and prune.

Concepts

Integration event. An IntegrationEvent — a fact announced to other services (contrast the in-process DomainEvent). IIntegrationEventBus.Publish(event) stages it.

Atomic staging. Publish doesn’t hit a broker — it resolves the outbox repository from the command’s ambient transaction and stages a Pending OutboxMessage row. The TransactionBehavior commits it with the aggregate’s events and read-model writes. One commit, no dual write.

Fail-loud outside a transaction. Publishing an integration event outside a command scope (no ambient transaction to stage onto) throws, rather than silently dropping the event — a deliberate guardrail.

The relay (article 014). A background OutboxProcessor claims committed Pending rows (FOR UPDATE SKIP LOCKED, so multiple instances never publish the same row), publishes them to the broker, and marks them Processed. Failures are retried with backoff and eventually dead-lettered. A crash after publish but before marking causes a re-publish — hence at-least-once, hence the inbox.

Architecture

sequenceDiagram
    participant H as Command handler
    participant Bus as IIntegrationEventBus
    participant Tx as Transaction (one commit)
    participant DB as PostgreSQL (state + relay_outbox_messages)
    participant P as OutboxProcessor (article 014)
    participant Broker as Message broker
    H->>Bus: Publish(OrderPlacedIntegrationEvent)
    Bus->>Tx: stage Pending outbox row (ambient transaction)
    Tx->>DB: COMMIT state + outbox row atomically
    Note over Tx,DB: handler throws ⇒ ROLLBACK, no outbox row
    P->>DB: claim committed Pending rows (FOR UPDATE SKIP LOCKED)
    P->>Broker: publish → mark Processed (at-least-once)

Building it step by step

The sample is samples/011-outbox.

1. Publish an integration event from a command

public sealed class PlaceOrderCommandHandler(IIntegrationEventBus integrationEventBus)
    : ICommandHandler<PlaceOrderCommand, string>
{
    public async Task<string> Handle(PlaceOrderCommand command, CancellationToken ct)
    {
        await integrationEventBus.Publish(new OrderPlacedIntegrationEvent { OrderId = command.OrderId }, ct);
        return "ok"; // the publish staged an outbox row in THIS command's transaction
    }
}

2. Map the outbox table and register the repository

// DbContext
modelBuilder.ApplyRelayEventStore();
modelBuilder.ApplyRelaySnapshots();
modelBuilder.ApplyRelayOutbox();          // relay_outbox_messages

// DI
services.AddRelayEventStoreEfCore<SampleDbContext>();
services.AddRelayUnitOfWorkEfCore<SampleDbContext>();
services.AddRelayOutboxEfCore<SampleDbContext>();   // IOutboxRepository
services.AddRelay(typeof(Program).Assembly);

The relaying processor (AddOutboxProcessor() + a broker) is article 014; staging needs only the above.

Complete source code

File Contents
Orders.cs The integration event + publishing commands
SampleDbContext.cs ApplyRelayOutbox()
OutboxAtomicityTests.cs The three atomicity properties

Running the example

dotnet test samples/011-outbox/Outbox.Sample.Tests   # needs Docker

Testing

The three tests pin the guarantee directly against PostgreSQL — the only place atomicity is real:

// commit: a published event is a Pending outbox row
await Bus.Execute<PlaceOrderCommand, string>(new PlaceOrderCommand(orderId), default);
rows.Should().ContainSingle(); rows[0].Status.Should().Be(OutboxMessageStatus.Pending);

// rollback: a failing command leaves NO outbox row (no dual write)
await act.Should().ThrowAsync<InvalidOperationException>();
(await OutboxRowCount(orderId)).Should().Be(0);

// fail loud: publishing outside a command transaction throws
await act2.Should().ThrowAsync<InvalidOperationException>();

The rollback test is the heart of it: prove that a failure after publishing leaves the outbox clean. That single assertion is the difference between the outbox and the broken dual-write.

Production considerations

  • Run the processor and monitor the backlog. Staging is useless without relaying (article 014). Add AddOutboxProcessor(), and alert on the outbox backlog / oldest-pending-age — a growing backlog means the broker is down or the processor stopped, and events aren’t reaching anyone.
  • Prune processed rows. The outbox grows; AddRelayMaintenance with an OutboxRetention deletes old Processed rows so the table doesn’t bloat.
  • At-least-once is the contract — consumers must dedup. A crash after publish re-publishes; design every consumer with the inbox (article 013). Never assume exactly-once delivery.
  • Multi-instance is safe by design. The processor claims rows with FOR UPDATE SKIP LOCKED, so you can run many instances without double-publishing. A stale lease (crashed processor) is reclaimed.
  • Keep payloads modest. Huge event payloads bloat the outbox and the broker; for large blobs use the claim-check pattern (a pointer in the event, the blob in object storage).

Common mistakes

  • Dual-writing anyway. Saving state then calling the broker directly from the handler reintroduces every failure mode the outbox exists to remove. Publish via IIntegrationEventBus; never touch the broker from a command handler.
  • Using the outbox for in-process reactions. A read-model update doesn’t need a broker round-trip; it’s a domain event in the same transaction. The outbox is for crossing a boundary.
  • Publishing outside a transaction and being surprised it throws. The fail-loud guard is intentional — there’s no ambient transaction to stage onto. Publish from within a command.
  • Forgetting to run/monitor the processor. Staged-but-never-relayed events pile up silently. The processor and its backlog alert are not optional in production.
  • Assuming exactly-once delivery. It’s exactly-once persistence, at-least-once delivery. Skipping the inbox is the bug that ships duplicate effects.

Tradeoffs

Benefits. No dual-write: state and the intent-to-notify commit atomically; durable events that survive broker outages; multi-instance-safe relay; and a clean split of exactly-once persistence from at-least-once delivery.

Costs. An outbox table, a processor to run and monitor, retention to manage, a small publish latency, and the at-least-once contract that pushes dedup onto consumers (the inbox).

Alternatives

  • Direct dual-write (save, then publish). Simplest to write, unsafe — the lost/phantom-event bugs are not theoretical. Acceptable only when event loss is genuinely tolerable.
  • Distributed transactions / 2PC across DB and broker. Atomic in theory; slow, operationally fragile, poorly supported by modern brokers, and broadly abandoned. The outbox is the pattern that replaced it.
  • Change Data Capture (CDC) as the outbox. Stream the DB’s change log to the broker instead of an explicit outbox table. Avoids the table, but couples your event contract to your table schema and adds a CDC pipeline to operate. A legitimate variant; Relay’s explicit outbox keeps the event a first-class, schema-stable contract.
  • Listen-to-yourself with the event store. If you’re event-sourced, a subscription over the event log can drive publishing. Relay’s outbox integrates with the same transaction and gives explicit retry/dead-letter control; the two can coexist.

Objectively: for “a committed state change must reliably notify another system,” the outbox is the standard, correct answer; the alternatives are either unsafe (dual-write) or heavier (2PC, CDC).

Lessons from production systems

  • The dual-write bug is silent until it’s a data-integrity incident. Teams that “just publish after saving” run fine for months, then a broker blip drops events and downstream stores quietly diverge. The outbox eliminates the class of bug; adopting it preventively is far cheaper than reconciling drift.
  • A growing outbox backlog is the best early-warning signal you’ll get. When the broker degrades, the outbox fills before anything else breaks. Teams that alert on backlog age catch broker problems early; teams that don’t find out from downstream consumers.
  • At-least-once surprises teams that skipped the inbox. “Why did we send two emails / charge twice?” is the outbox-without-inbox signature. Treat the outbox and inbox as a pair from day one.
  • Retention matters more than people expect. An unpruned outbox becomes the biggest, hottest table in the database. Set retention when you set up the processor, not after the table is 200 GB.

Should you use this?

Situation Recommendation
A committed state change must reliably reach another service Strong fit — the canonical use
Cross-service events that must not be lost or phantomed Strong fit
In-process reactions (read models, audit) No — use domain events (article 003)
Best-effort telemetry where loss is fine Lean against — durability is overhead
Synchronous request/response No — that’s an RPC, not an event
No transactional store No — nothing to stage onto

Next steps

Staged events are useless until something carries them to other services. The next article covers the carrier: the transport abstraction (broker + consumer) the outbox processor publishes to, and the in-memory / RabbitMQ / Azure Service Bus implementations behind it.

➡️ 012 — Messaging & Transports