Lesson 36 · The outbox pattern · Module 5

The Outbox

Save the row, publish the event. Two writes, two systems, one request — and nothing whatsoever holding them together. Sooner or later the first succeeds and the second does not, and the two stores disagree for ever without anybody being told.

The win in this lesson: you will spot a dual write in any design on sight, you will know the two-line fix, and you will know exactly what that fix costs — publishing becomes asynchronous, so everything downstream is now eventually consistent.

1. The dual write

The code is so ordinary that it reads as correct:

order = orders.insert(...)        # database
queue.publish("OrderCreated", …)  # broker
return 201

Two network calls to two systems that have never heard of each other. There is no transaction spanning them, no shared commit record, nothing that fails both together. So watch what happens when the second one does not answer.

client order service database queue the half that works POST /orders BEGIN · INSERT INTO orders · COMMIT committed → durable, and from this instant irreversible the half with nothing holding it publish OrderCreated → broker timeout · process killed · network gone × the row is committed, the event does not exist, and no rollback is left to take both systems are healthy · each is internally consistent · neither can see the other 201 Created · the order exists, as far as anyone asking can tell orders: 1 row durable and correct topic: nothing for ever
Nothing here is broken. No component misbehaved, no alert fires, and the request even succeeded. The shipping service simply never learns that an order exists, and the discrepancy surfaces weeks later as a human comparing two reports that should have matched.

Swap the order and you swap the bug, not the risk. Publish first and the commit may fail: now a consumer is acting on an order the database has never heard of. That one is worse, because it is contagious — downstream systems build state on an event that describes nothing.

2. Why you cannot make it atomic

Every arrangement of two writes to two systems has the same hole in it. There is no ordering that closes it, because the hole is the gap between the two calls, and that gap always exists.

ArrangementWhat failsWhat you are left holding
Database, then publishThe publishState with no event. Downstream never acts. Silent.
Publish, then databaseThe commitEvent with no state. Downstream acts on a fiction.
Publish, then commit, then retry the publishThe process, between the two Same two holes, plus duplicates when the retry lands.
Two-phase commit across bothThe coordinatorAtomicity — at the price of Lesson 18's blocking window.

The fourth row is the one people reach for, and it is why Lesson 18 matters here. Two-phase commit really does give you the all-or-nothing outcome. It also makes both participants surrender the right to decide alone, so a coordinator that dies between phases leaves them holding locks with no legal move. Most brokers cannot join such a protocol anyway.

The idea worth keeping

You cannot get atomicity across two systems by being careful about ordering. You get it by having only one system to commit to. So stop writing to two: write both facts into the database you were already committing to, and let something else move the second one.

3. The outbox

"The outbox pattern is a way to safely and reliably exchange data between multiple (micro) services. An outbox pattern implementation avoids inconsistencies between a service’s internal state (as typically persisted in its database) and state in events consumed by services that need the same data."

Debezium, Outbox Event Router

The outbox is an ordinary table in your ordinary database. Debezium's default expects these columns:

"To apply the default outbox event router SMT configuration, your outbox table is assumed to have the following columns:"

Debezium, Outbox Event Router, "Basic outbox table"
Column        |          Type          | Modifiers
--------------+------------------------+-----------
id            | uuid                   | not null
aggregatetype | character varying(255) | not null
aggregateid   | character varying(255) | not null
type          | character varying(255) | not null
payload       | jsonb                  |

And the fix to the request path is two lines — one added, one deleted:

BEGIN;

INSERT INTO orders (id, customer_id, total)
VALUES ('1', 123, 69.97);

INSERT INTO outbox (id, aggregatetype, aggregateid, type, payload)   -- added
VALUES (gen_random_uuid(), 'orders', '1', 'OrderCreated',
        '{"id": 1, "customerId": 123}');

COMMIT;            -- both rows, or neither. there is no third outcome.

-- queue.publish(...)                                               -- deleted

That is the whole trick. The event is no longer a message sent over a network that may or may not arrive; it is a row, protected by the same commit as the state change it describes. The database's atomicity, which you were already paying for, now covers both facts.

4. The relay, and where you have seen it before

A committed outbox row still has to reach the broker. A separate process reads the rows and publishes them — and the good version does not query the table at all. It reads the log the database is already writing:

"The MySQL connector uses a client library for accessing the binlog[…] The PostgreSQL connector reads from a logical replication stream."

Debezium, Debezium Architecture

Which should feel extremely familiar. Lesson 01's mcsqueal tailed the replication stream to invalidate caches. Lesson 07's migration wrote an audit log row inside the request and had a catch-up process replay it — explicitly not writing to both stores inline, for exactly the reason this lesson opens with. The outbox is that same move, generalised: record the intent durably in one commit, deliver it asynchronously. Lesson 07 replayed into a second database; here you replay into a broker.

order service database relay (CDC) broker consumer one transaction · one commit · two rows BEGIN INSERT orders INSERT outbox COMMIT both rows, or neither — one atomic decision, in one system nothing above this line has spoken to the broker log record: INSERT on outbox, at LSN 24064704 publish → outbox.event.orders, key = aggregateid OrderCreated the relay dies here — after the publish, before recording how far it had read on restart it resumes from its last recorded position, which is before that row publish again · same id in the header OrderCreated, a second time id already seen → dropped the failure did not disappear — it moved to a place where retrying is safe and lateness is visible
Compare the two bands. In the first diagram the window never closes and nobody knows it is open. Here the window closes as soon as the relay catches up, and its width is a number you can graph: relay lag. That is the entire upgrade.

Debezium hands you the deduplication key in the message itself: the id column "Contains the unique ID of the event. In an outbox message, this value is a header. You can use this ID, for example, to remove duplicate messages."

5. What it costs

The outbox does not remove failure. It moves failure to the one place where retrying is harmless, and converts an invisible divergence into a visible delay. That trade has a bill.

Duplicates are now certain, not possible

The relay publishes, then records its position. A crash between those two acts republishes. This is at-least-once delivery — Lesson 35's subject — and it is not a defect you can configure away. Consumers must be idempotent, keyed on the event id.

Ordering is commit order, per key — and nothing more

txn A txn B database log consumer A starts first and finishes last A: BEGIN, insert B: BEGIN, insert B COMMIT → appended first A COMMIT → appended second delivered B, then A the relay orders by commit, not by BEGIN — and only within a single key two different aggregateid values can land on different partitions and race each other so never encode "this happened after that" across keys. the log will not preserve it.
This is why the key column exists at all. Debezium routes on aggregateid: it "Contains the event key, which provides an ID for the payload. The SMT uses this value as the key in the emitted outbox message. This is important for maintaining correct order in Kafka partitions." One order's events stay in sequence relative to each other. That is the only ordering you are sold.

The rest of the bill

Residual riskWhy it appearsWhat you do about it
Eventual consistency downstreamPublishing is no longer part of the request Graph relay lag and alert on it. Never promise a caller the event has been delivered.
Duplicate deliveriesPublish and position-recording are not atomic either Consumers dedupe on the event id header, or are naturally idempotent.
Outbox table growthEvery event is a row in your production database Delete published rows on a schedule, or partition by day and drop old partitions.
Write amplificationEach business write is now at least two writes Budget for it — Lesson 10's arithmetic applies.
A new component to operateThe relay can stall, lag, or fall behind log retention Monitor it as a production service. A silently stopped relay is the old bug, slower.

6. Check yourself

7. Back to your world

Open any service you own and search the request path for a database write and a network publish in the same function. Email, webhook, analytics event, cache invalidation, search index update, another team's API — they are all the same bug wearing different clothes. Then ask the three questions that decide the fix:

  • Which write is the source of truth? That one stays in the transaction. The other becomes an outbox row.
  • Can the consumer survive seeing it twice? If not, you have not finished. Delivery is at-least-once and no setting changes that.
  • Who deletes the old rows? An outbox nobody prunes becomes the largest table in your database, and you will find out during an incident.
The two-line fix, in one line

Replace the publish call with an INSERT into an outbox table inside the transaction you were already committing, and let a separate process read the committed log and publish. You give up synchronous delivery. You get back a system that cannot silently disagree with itself.

Ask me things. "show me a polling relay that is actually safe with two instances" · "how do I dedupe on the consumer side without unbounded state?" · "what breaks if the outbox table gets huge?" · "when is a dual write genuinely fine?" · "I think we should just retry the publish in a background job. Grill me."