A practical lesson on the transactional outbox pattern for keeping local state changes and event publication aligned without unsafe dual writes.
The transactional outbox pattern addresses one of the hardest producer-side reliability problems in event-driven systems: how to keep local state change and event publication aligned without pretending two independent systems can be updated atomically by wishful thinking. If a service writes business state successfully but fails to publish the event, downstream systems miss a fact that really happened. If it publishes the event but the local transaction rolls back, downstream systems react to a fact that never became true. The outbox pattern reduces this gap by turning publication into a controlled follow-on step after one local transaction.
The basic idea is simple. The service writes the business change and an outbox record in the same database transaction. A separate relay process later reads unpublished outbox rows and delivers them to the broker. That avoids the classic unsafe dual write in which the application tries to update the database and broker directly in one request handler.
sequenceDiagram
participant App as Service
participant DB as Local DB
participant Relay as Outbox Relay
participant Broker as Broker
App->>DB: Commit business row and outbox row in one transaction
Relay->>DB: Read unpublished outbox rows
Relay->>Broker: Publish event
Relay->>DB: Mark outbox row as published
What to notice:
The classic failure looks simple:
That leaves the system in an inconsistent state. Downstream consumers either miss a committed fact or react to one that never committed. The outbox pattern is useful because it avoids making the application choose between two external success conditions in one fragile step.
1create table outbox_events (
2 id varchar(50) primary key,
3 aggregate_type varchar(50) not null,
4 aggregate_id varchar(50) not null,
5 event_name varchar(100) not null,
6 payload json not null,
7 occurred_at timestamp not null,
8 published_at timestamp null
9);
The table does not need to look exactly like this, but the design usually includes:
The relay process may poll the outbox table, stream changes from it, or otherwise scan for unpublished records. Its job is to publish events reliably and then mark them as published. This means the relay is now part of the operational design. Teams need visibility into stuck rows, duplicate publication risk, and retry behavior.
The outbox pattern solves one important thing well: it aligns local business state and the existence of a publishable event record. It does not automatically solve:
That is why the outbox is a practical producer pattern, not a total reliability solution.
1outboxFlow:
2 transaction:
3 - write business row
4 - write outbox row
5 relay:
6 - scan unpublished rows
7 - publish to broker
8 - mark as published
9 stillNeeds:
10 - idempotent publishing
11 - relay monitoring
The outbox pattern fits best when:
It is less relevant in cases where the data store itself is the stream of record or where the system already uses a different integrated change-publication model.
A service writes a new order and then publishes order.placed directly from the request handler after the transaction commits. If the publish call fails, the team plans to “check logs later.” Why is that weak compared with an outbox?
Because logs are not a durable, structured publication queue. The committed business state now exists without a guaranteed relay path for the event. The outbox pattern turns that missing event into a durable local record that can be retried and monitored instead of a best-effort publish followed by hope.