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.
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
- Watch
- Try it
- Predict
- Capture
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.
Highlighted lines are the ones running in the diagram right now.
def placeOrder(input):tx = db.begin() # BEGINtx.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
def transform(rawEvent):# rawEvent is a Debezium 'c' (insert) on the outbox table.row = rawEvent.afterreturn KafkaRecord(topic = topicPrefix + row.event_type, # OrderPlaced → orders.eventskey = row.aggregate_id, # routes by aggregatevalue = row.payload, # domain event, not row deltaheaders = { 'eventId': row.id },)
# 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.
def placeOrder_dualWrite(input):db.execute("INSERT INTO orders(id, status, amount)"" VALUES (?, 'pending', ?)",input.id, input.amount,)# ⚠ no shared atomicity past this pointkafka.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