The outbox is a contract, not a table — the transactional outbox and Debezium's Outbox Event Router

Writing the event into a dedicated outbox table inside the same transaction as the business write makes the event commit atomically with the row, and CDC tailing the outbox emits domain events whose schema is decoupled from the business tables.

Previously

CDC of business tables couples consumers to internal table shape; the way out is to stop treating tables as the contract and start emitting domain events from a table that exists only to be published. That table is the outbox.

Scene 08

The outbox is a contract, not a table

  1. Watch
  2. Try it
  3. Predict
  4. Capture
BEGINCOMMITINSERT INTO ordersINSERT INTO orders (id=42, status='pendin…pendingINSERT INTO outboxINSERT INTO outbox (aggregate_id=42, even…pendingWAL · single xid commits BOTH records togetherDebeziumfilter: outbox onlyorders rows ignoredOutbox Event Router · SMTaggregate_id → keyevent_type → topicpayload → value(no event yet)CDC orders tableop=u, before={amount:50}, a…source: orders.id, orders.u…leaks every columnCDC outbox tableevent_type: OrderPlacedpayload: { orderId, items, total }domain event · stable contract
What to watch for

A user clicks 'place order'. We need the OrderPlaced event to commit atomically with the orders row — anything less reopens the dual-write gap from scene 1. The DB already gives us atomicity for two rows in the same transaction. So write both: the business row, and an event row. That second table — the outbox — exists for one purpose: to be published. Watch the transaction commit, then watch the connector emit one domain event.

Continue unlocks when the animation finishes.
Implementation

Highlighted lines are the ones running in the diagram right now.

Service.placeOrder(input)
one transaction, two INSERTs — atomic by construction
def placeOrder(input):
tx = db.begin() # BEGIN
tx.execute(
"INSERT INTO orders(id, status, amount)"
" VALUES (?, 'pending', ?)",
input.id, input.amount,
)
tx.execute(
"INSERT INTO outbox"
" (aggregate_id, event_type, payload)"
" VALUES (?, 'OrderPlaced', ?)",
input.id, domainEvent(input),
)
tx.commit() # both rows or neither
OutboxRouter.transform(rawEvent)
Single Message Transform — outbox row to domain event
def transform(rawEvent):
# rawEvent is a Debezium 'c' (insert) on the outbox table.
row = rawEvent.after
return KafkaRecord(
topic = topicPrefix + row.event_type, # OrderPlaced → orders.events
key = row.aggregate_id, # routes by aggregate
value = row.payload, # domain event, not row delta
headers = { 'eventId': row.id },
)
compareTo_directCdc()
what reaches the topic — row delta vs. domain event
# CDC of the orders table (no outbox):
# topic = db.public.orders
# key = pk(id=42)
# value = { op: 'c', after: {
# id, customer_id, status, amount, ... } }
# ↑ every column on the wire; rename leaks straight through.
# CDC of the outbox table (+ Outbox Event Router SMT):
# topic = orders.events
# key = aggregate_id
# value = OrderPlaced{ orderId, total, currency }
# ↑ domain-shaped; business-table DDL is invisible.
Service.placeOrder_dualWrite(input) # anti-pattern
the broken version from scene 1 — for contrast only
def placeOrder_dualWrite(input):
db.execute(
"INSERT INTO orders(id, status, amount)"
" VALUES (?, 'pending', ?)",
input.id, input.amount,
)
# ⚠ no shared atomicity past this point
kafka.send('orders.events',
key=input.id,
value=domainEvent(input))
# crash here → row committed, event lost (or vice versa)

Where this sits in Build a CDC pipeline (Debezium + outbox)

Scene 08 of 12. Write the event into a dedicated outbox table inside the same transaction as the business write — the DB transaction makes both atomic, and CDC tailing the outbox emits domain events decoupled from the business tables.

Up next. The outbox closes the dual-write gap — but every business write now also writes a row whose entire purpose is to be deleted, so the table will grow forever unless we pick a cleanup strategy.

All 12 scenes in Build a CDC pipeline (Debezium + outbox) · Every curriculum

Built with Arqly
Every scene in Build a CDC pipeline (Debezium + outbox) builds on the one before it.All 12 Build a CDC pipeline (Debezium + outbox) scenes