Essential 03

The Outbox Pattern

Save the row. Publish the event. Two statements, one method, and no way in the world to make them happen together, because they land in two systems that fail independently and neither one can roll back the other. This page is about the window between those two lines: how wide it is, what falls into it, and why the fix is not a distributed transaction but a table.

Level Advanced Stack Java 17 · Spring · JPA · PostgreSQL Status Prescriptive, not yet built
Read this as a proposal, not as a report

There was no outbox table, no message relay and no change-data-capture pipeline anywhere in the estate this hub was written against, and none was introduced afterwards. Everything below is what to build and what it will cost: a design under consideration, with its trade-offs stated. The problem it answers was verified by reading the source tree; the solution was not implemented, and no part of this page is a claim of work done.

The claim under test

This is the most common shape in enterprise event-driven code. It appears in every tutorial, it passes every code review, and it is wrong in a way that unit tests cannot reach.

javathe two lines this page is about
@Transactional
public Order place(PlaceOrder cmd) {
    Order order = repository.save(Order.from(cmd));      // system 1: the database
    publisher.publish(new OrderPlaced(order.getId()));   // system 2: the broker
    return order;
}

Line three is a local transaction. Line four is a network call to a completely different piece of infrastructure, with its own availability, its own failure modes and its own idea of what has happened. @Transactional has authority over the first and none over the second: the broker is not enrolled in the transaction, cannot be told to roll back, and will not undo a message it has already accepted.

So line four is a distributed transaction in disguise. The method claims an atomic outcome, the order exists and the world was told, across two resources that have no shared commit protocol. Chris Richardson states the consequence without hedging, and the important part is that it holds in both directions:

But without using 2PC, sending a message in the middle of a transaction is not reliable. There's no guarantee that the transaction will commit. Similarly, if a service sends a message after committing the transaction there's no guarantee that it won't crash before sending the message.

Two sentences, two orderings, two different bugs. Reordering the statements does not remove the problem; it only chooses which failure you get.

The dual-write windows

Draw it as a timeline and the argument stops being abstract. There are exactly two orderings available to you, each has an interval between the two writes, and a process can die inside that interval.

Ordering 1 · publish, then commit time → the process dies here publish(event) the broker has it now failure window no code of yours runs here COMMIT …or ROLLBACK PHANTOM EVENT: consumers reacted to something that never happened a rollback cannot recall a message the broker has already delivered Ordering 2 · commit, then publish time → the process dies here COMMIT the row is durable failure window no code of yours runs here publish(event) never runs LOST EVENT: the state changed and nothing downstream is told no exception is raised anywhere; the divergence is silent and permanent
The red band is the whole problem. It is not a line of code. It is the interval between two writes to two systems, and it is where the process is allowed to die. Above, the broker already has a message the database is about to disown. Below, the database has a fact the broker will never carry. The band has non-zero width in every possible ordering, which is why no amount of reordering closes it.

The two outcomes are worth naming separately, because they are found by different people at different times and they cost different amounts to repair.

Phantom event: publish, then commit

The message went out and the transaction did not commit. Downstream services have allocated stock, charged a card, dispatched a courier or emailed a customer about an order that does not exist in the system of record.

Discovered by reconciliation, usually days later, usually by finance. Repair means compensating actions in every system that reacted.

Lost event: commit, then publish

The transaction committed and the message did not go out. The order exists, is correct, and is invisible: no shipment, no projection row, no audit entry, no alert.

Discovered by a customer, because nothing in the system looks for an event that was never emitted. There is no error, no retry, no queue depth, no alert to fire.

try/catch does not close the window, and this is the part people argue about

The instinct is to wrap the publish in a try, retry on failure, and roll the transaction back if the retries are exhausted. That handles the case where the broker fails and your process survives to notice, which is the easy half.

It does nothing about the case the window actually describes: the process itself stops existing mid-interval. A kill -9, an OOM kill, a pod eviction, a node losing power, a container hitting its memory limit during a rolling deploy. No catch block runs, no finally runs, no shutdown hook runs. Retries make the window narrower and considerably harder to reason about; they never make it zero.

This is the test to apply to any proposed fix: does it still work if the JVM ceases to exist between the two statements? Almost every fix that is not the outbox fails it.

Why not two-phase commit

There is a protocol designed for exactly this, and dismissing it in one line is the standard mistake. XA and two-phase commit are real, mature, and still in production service: a transaction manager asks every participant to prepare, and only when all of them have durably promised does it ask them all to commit. In a JTA container with an XA-capable relational database and an XA-capable JMS provider, the code in the first section of this page is genuinely correct. That configuration is not a myth; it ran the enterprise for two decades.

Richardson still rules it out first, and states the reason as a force rather than an opinion:

2PC is not an option. The database and/or the message broker might not support 2PC. Also, it's often undesirable to couple the service to both the database and the message broker.

The first clause is decisive in practice, and the Debezium team put the specific case plainly when introducing the pattern:

The reason being that we cannot have one shared transaction that would span the service's database as well as Apache Kafka, as the latter doesn't support to be enlisted in distributed (XA) transactions.

Where XA still earns its keep
  • A homogeneous estate: one relational database and one XA-capable JMS or AMQP broker, both administered by the same team.
  • An application server or Spring Boot with a JTA transaction manager already configured, tested and understood.
  • Low throughput, high value per transaction, and a genuine business requirement for a single atomic outcome across both resources.
  • A team that already knows how to resolve an in-doubt transaction at 3 a.m., because eventually one of them will have to.
Why it is not the answer here
  • Broker support. Kafka does not participate in XA at all. Neither do Redis, SQS, SNS, NATS or most cloud-managed buses. The protocol is unavailable, not merely unfashionable.
  • Coordinator availability. The transaction manager becomes a new stateful component on the critical path, with its own durable log that must survive the crash it exists to arbitrate.
  • Blocking. 2PC blocks. A participant that has prepared holds its locks until the coordinator tells it how the story ends; if the coordinator is unreachable, those locks are held indefinitely.
  • Latency. Two coordinated round trips per participant, each with a forced log flush, on every single write.
  • Operational cost. Recovery scans, heuristic outcomes needing manual resolution, and, on PostgreSQL, prepared transactions that pin WAL and locks, behind a max_prepared_transactions setting that ships disabled.

The honest summary is not "2PC is bad". It is that 2PC solves the problem only when every participant speaks it, and in a modern estate the message broker usually does not. The outbox reaches the same guarantee by removing the second participant entirely.

The outbox, structurally

The move is almost embarrassingly simple once you see it: stop trying to write to two systems and write to one. The message becomes a row, the row goes into the same transaction as the business change, and something else takes it from there.

The solution is for the service that sends the message to first store the message in the database as part of the transaction that updates the business entities. A separate process then sends the messages to the message broker.

one local transaction Order service @Transactional orders INSERT the business row outbox INSERT the message row ONE COMMIT · both rows or neither no broker is enlisted in this transaction Message relay polls or tails the log mark delivered Broker durable · replayable at-least-once Consumer dedupes by message id
The dashed box is the guarantee. Both INSERTs are ordinary local writes to one database, so they share one COMMIT: the message row exists if and only if the business row does. Everything to the right of that box is a separate concern with its own retries and its own transaction, including the relay marking a row delivered, which is why that edge is dashed and points back in. The relay can crash, restart, or run twice without endangering the business data.

The benefit Richardson claims for this is precise, and it is the sentence to quote in a design review: messages "are guaranteed to be sent if and only if the database transaction commits". Both halves matter. No commit, no message, so the phantom is gone. Commit, then eventually a message, so the loss is gone.

One discipline goes with the table. The outbox is a queue that happens to be stored in your database, and treating it as anything else breaks the log-based relay:

All changes in an outbox table are expected to be INSERT operations. That is, an outbox table functions as a queue; updates to records in an outbox table are not allowed.

What actually changed

The outbox does not make a distributed write atomic. It eliminates the distributed write. What was "commit here and publish there" becomes "commit here" followed, at some later time, by "publish there, retrying until it works". The second half is allowed to fail as often as it likes, because the message it needs sits durably in a table that is not going anywhere. You have traded an impossible guarantee for a latency you can measure.

Richardson also lists the pattern's one real drawback, and it is a human one: "Potentially error prone since the developer might forget to publish the message/event after updating the database." A service method that writes a business row and no outbox row compiles, passes its tests, and silently drops the event forever. This is worth an architecture test or a code-review rule, not a wiki page.

Two ways to move the message

The outbox splits the problem in half; the second half is getting rows out of the table and into the broker. There are two named patterns for it and the choice is genuinely a trade, not a maturity ladder.

Polling Publisher

Publish messages by polling the database's outbox table.

A scheduled loop selects undelivered rows, publishes them, and marks them delivered. That is the entire mechanism. It needs no new infrastructure, no connector, no additional cluster, and it works on any SQL database you already run. It costs you a floor on latency, because no event can be delivered faster than the poll interval, and a query that runs forever whether or not there is anything to do. Multiple relay instances need SKIP LOCKED to avoid publishing the same row twice.

Transaction Log Tailing (CDC)

Tail the database transaction log and publish each message/event inserted into the outbox to the message broker.

Instead of asking the database what changed, you read the record it already keeps of what changed: the PostgreSQL write-ahead log, the MySQL binlog, a DynamoDB table stream. Debezium is the usual implementation, and its Outbox Event Router transformation reshapes each captured outbox row into a message routed by aggregatetype and keyed by aggregateid. Latency drops to the propagation delay of the log, and the polling load disappears entirely. In exchange you get a connector, a Kafka Connect cluster or equivalent to run it in, replication-slot configuration, and a component whose lag is now a thing you must monitor and whose failure mode is database-specific.

DimensionPolling PublisherTransaction Log Tailing / CDC
Latency Bounded below by the poll interval. Sub-second is achievable; sub-100 ms means polling hard enough to notice. Log propagation delay, typically single-digit milliseconds, and independent of your event rate.
Load on the database A query every interval per relay instance, whether or not there is work. Cheap with a partial index; not free, and it never sleeps. None from the relay. The connector reads the log the database is already writing, not the tables.
Infrastructure required None. A @Scheduled method inside the service you already deploy. A connector plus a runtime for it, a replication slot or binlog user, and usually Kafka Connect.
Operational complexity You can debug it with SELECT. The pending backlog is visible as rows. Replication slots that pin WAL if the connector stalls, snapshot modes, schema history, offset topics. Real expertise, or a managed service.
Ordering Whatever your ORDER BY and your locking strategy give you. Parallel relays trade ordering for throughput, see below. Commit order, from the log, for free. It is the database's own record of what happened when.
Delivery semantics At-least-once. A crash after publishing but before marking republishes. At-least-once. A connector restart replays from the last committed offset.
Characteristic failure Relay thread dies quietly and the backlog grows unnoticed. Alert on the age of the oldest undelivered row, not on process liveness. Connector stalls, the replication slot stops advancing, and WAL accumulates until the database runs out of disk. This has taken production down more than once.
Reach for it when You are adding the outbox to an existing service and want the guarantee this sprint, with nothing new to operate. You already run Kafka Connect or Debezium, or the latency floor of polling is genuinely too high for the use case.
Start with polling, and mean it

The guarantee this page is about is delivered entirely by the table. The relay is an implementation detail of how fast the rows drain, and swapping a polling relay for CDC later is a change to one component with no effect on the write path or on the consumers. Adopting Debezium first, because it is the version in the conference talk, front-loads all the operational cost before you have any of the benefit.

What the outbox does not give you

It gives you delivery. It does not give you exactly once, and any design that assumes otherwise is broken in a way that will only appear under failure. The pattern's own Issues section states the reason:

The Message relay might publish a message more than once. It might, for example, crash after publishing a message but before recording the fact that it has done so. When it restarts, it will then publish the message again.

Richardson's conclusion from that is unambiguous: the consumer must be idempotent, tracking the IDs of the messages it has already processed. This is the same window as the one at the top of this page, publish then record, moved one component to the right, and it is irreducible for the same reason. The difference is that here the failure is a duplicate rather than a loss, and a duplicate is something a consumer can be built to absorb.

That makes the outbox and the idempotent consumer a matched pair, not two independent choices. Everything in Message Brokers vs Event Buses about at-least-once delivery applies here unchanged: the id must be stamped by the producer rather than derived from broker metadata, the deduplication claim and the business write must share one transaction, and the dedupe table needs a retention policy longer than the maximum redelivery delay. The outbox is what makes at-least-once reachable from a database write; that page is what makes it safe once it arrives.

The Inbox pattern

The receiving-side mirror image is sometimes called the inbox: before doing any work, the consumer inserts the message id into an inbox table with a unique constraint, in the same transaction as the work itself. A duplicate delivery loses the race on the constraint and returns without acting. The outbox makes a write reliably publishable; the inbox makes a delivery reliably applied once. Neither is sufficient alone, and the pair is what people mean when they say effectively-once. Debezium's router leans on the same identifier for this: the outbox id column "Contains the unique ID of the event. In an outbox message, this value is a header."

Ordering, honestly

Ordering is where outbox write-ups tend to overpromise. Richardson lists it as a force rather than a benefit, and note how carefully he scopes it:

Messages must be sent to the message broker in the order they were sent by the service. This ordering must be preserved across multiple service instances that update the same aggregate.

The same aggregate, not the whole system. That is the guarantee to aim for, and three things have to line up to get it. A monotonic sequence_no in the outbox gives the relay a total order to read in. Concurrent writers to one aggregate serialise on that aggregate's row lock, so their outbox rows are assigned in the order their transactions actually committed. And the relay must publish with the aggregate id as the partition key, or the broker will re-order what the relay carefully ordered.

SKIP LOCKED and ordering pull in opposite directions

SKIP LOCKED is what makes several relay instances safe: each claims rows the others are not holding, so no message is published twice by two relays. What it does not buy is ordering. Two instances can each claim a different undelivered row for the same aggregate and publish them in either order, and per-aggregate ordering, the one guarantee this pattern can actually offer, is gone.

Pick one deliberately. One relay instance (with leader election or a lock, so a second replica stays idle) keeps ordering and caps throughput at one process. Sharded relays, where each instance claims a disjoint set of aggregates by a hash of the aggregate id, keep both, at the cost of a rebalancing story. Parallel relays over one undifferentiated queue buy throughput and give up per-aggregate order; that is a fine trade for independent events and a silent corruption for a state machine.

Global ordering across all aggregates is not on the menu. A partitioned broker cannot offer it, a single partition cannot scale to it, and almost no domain needs it. Requiring it is usually a sign that two aggregates should have been one.

The gap this would close

The starting condition: structurally verified, and still unsolved

In an eleven-service estate, these facts were established by reading the source tree rather than inferred:

  • Services persisted through JPA to a relational store and then published domain events to a Redis pub/sub bus from the same service method. That is precisely the two-line shape at the top of this page, with a bus at the other end that offers less than most.
  • Redis pub/sub is fire-and-forget and at-most-once: no persistence, no replay, no consumer groups, and no broker acknowledgement. The reply to a publish is a count of sockets written to, not a delivery receipt.
  • Two independent failure windows therefore existed. The publish could fail after the transaction had already committed, which is the lost event in the lower half of the diagram above. And even a successful publish was discarded if no subscriber happened to be connected at that instant, which is a loss the write path cannot see at all.
  • The team recognised the delivery gap and hand-built an at-least-once layer: a pending-event store, an unacknowledged set per user, a dead-letter key, a retry scheduler and a client acknowledgement endpoint. That is a correct diagnosis and real engineering.
  • But that layer sat above the bus and covered only the final server→client hop. The service → bus → gateway hop remained at-most-once. If the gateway was the process that restarted, the event never reached the pending store to be retried; if the publish failed after commit, there was nothing to retry from.

The outbox is exactly the missing piece for that first hop. It is the only one of the two halves that can be closed at the point of the write, because it is the only one that can share a transaction with it. No outbox table, message relay or CDC pipeline existed in that estate, and none was introduced.

The full analysis of what that bus does and does not guarantee is in Message Brokers vs Event Buses. That page names the gap and the half of it that was closed; this page is the other half.

service/…/*ServiceImpl.java redisTemplate.convertAndSend(channel, payload) gateway/…/PendingEventStore.java

Doing it properly

Four tabs, in the order the change actually happens. The first is what is being deleted; the second is the table that replaces it; the third is the write path, which is where the whole guarantee lives; the fourth is the relay and the consumer that has to tolerate its retries. Everything is PostgreSQL, because SKIP LOCKED syntax is dialect-specific, and it exists in PostgreSQL 9.5+, MySQL 8.0+ and Oracle, and does not exist in SQL Server, where the equivalent is the READPAST table hint.

javaorder/OrderService.java · both orderings, both broken
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orders;
    private final DomainEventPublisher publisher;
    private final TransactionTemplate txTemplate;

    /**
     * Ordering 1: publish inside the transaction. The message leaves the
     * process before the COMMIT that would justify it, so a constraint
     * violation, a deadlock or a connection reset on the way out produces a
     * phantom: consumers have reacted to an order that does not exist.
     */
    @Transactional
    public Order placeThenCommit(PlaceOrder cmd) {
        Order order = orders.save(Order.from(cmd));
        publisher.publish(new OrderPlaced(order.getId(), order.total()));
        return order;                     // the COMMIT happens after this line
    }

    /**
     * Ordering 2: publish after the transaction commits. Now the row is
     * durable and the message is not. A try/catch around the publish does not
     * help: the failure this is about is the JVM being killed between the two
     * statements, and no handler runs in a process that no longer exists.
     */
    public Order commitThenPublish(PlaceOrder cmd) {
        Order order = txTemplate.execute(tx -> orders.save(Order.from(cmd)));
        try {
            publisher.publish(new OrderPlaced(order.getId(), order.total()));
        } catch (BrokerException e) {
            // Retrying here narrows the window. It does not close it, and a
            // retry loop that outlives the request thread is a second bug.
            log.error("event lost for order {}", order.getId(), e);
        }
        return order;
    }
}

Operational notes

The outbox is the rare pattern whose tutorials are complete about the happy path and silent about the parts that page you. Three of them matter more than the rest.

The table grows forever

Every message your service has ever emitted is a row, and marking a row delivered does not make it smaller. At a thousand events a minute that is half a billion rows a year, an index that no longer fits in cache, autovacuum falling behind, and a poll query that gets slower every week. Cleanup is not housekeeping; it is part of the design. Delete or archive delivered rows on a schedule, or partition by day and drop whole partitions, and dropping a partition is a metadata operation, whereas a large DELETE generates the dead tuples that create the next problem.

With a log-based relay you can go further and delete each row in the same transaction that inserted it: the connector reads the log, not the table, so the INSERT is captured even though the row never survives to be queried. Debezium's write-up of the pattern notes exactly this: "The calls to persist() and remove() will create an INSERT and a DELETE entry in the log once the transaction commits." The table stays permanently empty and the messages still flow. This trick is unavailable to a polling relay, which needs the row to still be there to find it.

Relay lag is the metric, not relay liveness

A relay process that is running, healthy, and publishing nothing looks identical to a healthy idle system on every dashboard that measures uptime. The signal that distinguishes them is the age of the oldest undelivered row: it is near zero when things are fine and grows without bound the moment they are not. Alert on that, and on the count of dead-lettered rows, which should be zero and is otherwise a queue of things nobody has looked at.

Poison messages block the queue

A message that cannot be published, whether a payload the serializer rejects, a topic that does not exist, or a record above the broker's size limit, will be retried on every tick forever. If the relay preserves ordering by refusing to publish past a failure, that single row now blocks every message behind it, and the outage is total rather than partial. Cap the attempts, park the row, alert, and let the queue drain. Parking is a deliberate consistency decision, not a cleanup: you have chosen to skip a message to keep the rest moving, and somebody has to be told.

Canonical sources