- Book: Event-Driven Architecture Pocket Guide: Saga, CQRS, Outbox, and the Traps Nobody Warns You About
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You write the order to Postgres. Then you publish OrderPlaced to Kafka. Two systems, two writes, no shared transaction. The database commits. The broker call times out. Now you have an order nobody downstream knows about.
That's the dual-write problem, and there are two mainstream answers to it in 2026. Transactional outbox: write the event to a table in the same transaction as the business data, then relay it out. Change data capture: skip the event entirely, tail the database's write-ahead log, and turn row changes into a stream.
Both solve the dual-write race. They solve it at opposite ends of the stack, and that single choice ends up shaping coupling, ordering guarantees, and your on-call rotation for years. Pick on purpose.
What outbox actually commits
The outbox pattern keeps the event inside the transaction boundary you already trust. One table, one insert, same commit as the order row.
BEGIN;
INSERT INTO orders (id, customer_id, amount_cents, status)
VALUES ($1, $2, $3, 'placed');
INSERT INTO outbox (id, aggregate_id, type, payload)
VALUES (
gen_random_uuid(), $1, 'OrderPlaced',
jsonb_build_object(
'order_id', $1,
'customer_id', $2,
'amount_cents', $3
)
);
COMMIT;
If the commit succeeds, both rows are durable. If it fails, neither exists. There is no window where the order is saved but the event is lost. A separate relay process reads unpublished rows and pushes them to the broker.
func (r *Relay) drain(ctx context.Context) error {
rows, err := r.db.Query(ctx, `
SELECT id, type, payload FROM outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var ev OutboxRow
if err := rows.Scan(
&ev.ID, &ev.Type, &ev.Payload,
); err != nil {
return err
}
if err := r.broker.Publish(ctx, ev); err != nil {
return err // retry next tick
}
r.markPublished(ctx, ev.ID)
}
return rows.Err()
}
The thing to notice: the event payload is your own shape. You decided OrderPlaced carries order_id, customer_id, amount_cents. The database schema and the event schema are separate contracts, and you control both. That separation is the whole argument for outbox.
FOR UPDATE SKIP LOCKED lets multiple relay workers drain the same table without stepping on each other. It's the line most homegrown outboxes forget, and the one that turns a single-threaded relay into a bottleneck under load.
What CDC reads instead
Change data capture never asks the application to write an event. Debezium connects to the Postgres logical replication slot (or the MySQL binlog) and reads committed row changes straight from the write-ahead log.
An INSERT into orders becomes a message like this:
{
"op": "c",
"source": { "table": "orders", "lsn": 23998123 },
"after": {
"id": "9f3c...",
"customer_id": "c-7781",
"amount_cents": 4200,
"status": "placed"
}
}
No outbox table. No relay process you maintain. The application keeps doing normal database writes and Debezium turns the log into a topic. For a team that wants events out of a system whose code they can't touch, that is the selling point.
But read that payload again. The event is your table. Columns become fields. A migration that renames amount_cents to amount_minor is now a breaking change to every downstream consumer, even though you only meant to tidy up a column name. CDC couples your event contract to your storage schema. Outbox was built to avoid exactly that.
Coupling: the difference that lasts
This is where the decision compounds over years, not sprints.
With outbox, the producer says what an event means. You can split one table into three and keep emitting the same OrderPlaced. You can add a column nobody downstream cares about and no consumer notices. The event is a deliberate public API.
With CDC, the database schema is the public API, whether you meant it to be or not. Every consumer is now reading your private storage layout. That works fine until the day someone normalizes a table, adds a join, or moves a field to a side table for performance. The refactor you'd do without a second thought becomes a cross-team migration.
There's a partial fix on the CDC side: the outbox-CDC hybrid, where you still write to an outbox table and point Debezium at that table instead of your domain tables. You get CDC's relay reliability and outbox's contract control. More on that below, because it's where a lot of teams land.
Ordering: per-key is easy, global is a lie
Both patterns give you ordering per aggregate, and neither gives you a clean global order.
Outbox preserves order if your relay reads ORDER BY id and you key the broker partition by aggregate_id. Events for one order arrive in the order they committed. Events across different orders interleave however the relay drains them, which is fine, because cross-aggregate global order is rarely a real requirement.
CDC preserves order per row because the WAL is the source of truth for what committed when. Debezium stamps each change with its log sequence number, so a single row's history is exact. Across rows it's the same story as outbox: per-key clean, global fuzzy.
The CDC ordering trap is the snapshot. When Debezium first connects, it takes a consistent snapshot of existing rows, then switches to streaming new changes. A row that changes during the snapshot can produce a snapshot read and a streaming event for the same state. Consumers have to be idempotent, which they should be anyway, but plenty of teams discover this the hard way during the first failover, not the first deploy.
Ops cost: who you page at 3am
Outbox is code you own. The failure modes are yours: the relay falls behind, the outbox table grows because nobody added a cleanup job, a poison payload jams the drain loop. You debug these with the same skills you debug any service. No new infrastructure, but a moving part in every service that emits events.
CDC is infrastructure you operate. Debezium runs on Kafka Connect, which is another cluster with its own scaling, rebalancing, and connector-restart behavior. The Postgres replication slot is the dangerous one: if a connector dies and nobody notices, the slot holds WAL segments the database can't recycle. The disk fills. The database stops accepting writes. That outage is not theoretical, it's one of the failure modes CDC operators warn about most, and it takes down the whole database, not just the pipeline.
Pick your poison by which team has the muscle. Outbox leans on application engineers. CDC leans on a platform team that already runs Kafka Connect and knows how to alert on replication-slot lag.
The decision matrix
| Dimension | Transactional outbox | Debezium-style CDC |
|---|---|---|
| Atomic with business write | Yes, same transaction | Yes, reads committed WAL |
| Event contract | You design it, decoupled from schema | Equals your table schema |
| Schema refactors downstream | Free, consumers don't see storage | Breaking, columns are the API |
| Per-key ordering | Yes, via relay + partition key | Yes, via WAL + LSN |
| New infrastructure | None, just a table + relay | Kafka Connect + replication slot |
| Touches application code | Yes, every emitting service | No, reads the log |
| Worst failure mode | Relay lag, unbounded table growth | Stuck slot fills disk, DB halts |
| Best fit | Greenfield, you own the producers | Legacy/third-party DBs you can't change |
Read the contract row twice. It's the one that bites in year two, long after the dual-write race you were originally solving has been forgotten.
How to pick
If you own the producing services and you're building events into them from the start, choose outbox. The event contract stays yours, you add no infrastructure, and the relay is a few hundred lines you understand top to bottom. This is the default for greenfield.
If you need events out of a database you don't control, a legacy monolith, a vendor app, anything where adding an outbox insert isn't an option, choose CDC. Reading the log is the only way in when you can't change the writer.
If you already run Kafka Connect and have a platform team comfortable with replication slots, CDC's ops cost is mostly paid for, and it scales to many tables without touching application code each time.
If schema stability across teams matters more than anything, lean outbox, or use the hybrid below to get CDC's mechanics without exposing your tables.
The hybrid most large systems end up running
The split isn't always a choice between two camps. The pattern that keeps showing up combines them: write to an outbox table for contract control, then let CDC do the relaying.
BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox (id, aggregate_id, type, payload)
VALUES (...);
COMMIT;
You point Debezium at the outbox table, not at orders. Now the event Debezium emits is your designed payload, not your raw table. You get:
- Atomicity from the single transaction, same as plain outbox.
- A maintained relay (Connect + Debezium) instead of a hand-rolled drain loop you have to scale and monitor yourself.
- Contract decoupling, because consumers read the outbox payload, never your domain tables.
Debezium even ships an "outbox event router" transform for exactly this, mapping outbox rows to topics by their type and aggregate_id columns. You still pay the CDC ops cost, the replication slot still needs slot-lag alerting, but you stop coupling consumers to storage and you stop maintaining relay code.
The honest tradeoff: you've added Kafka Connect to a problem a 200-line relay could have solved. For three services, that's overkill. For thirty services emitting events from a shared Postgres, the operational consolidation is worth it. The hybrid is an answer to scale, not a default.
The one question to ask first
Before the matrix, before the proof of concept, ask one thing: do you own the code that writes to this database? That single answer sets the frame. Ordering, ops cost, the hybrid, all of it follows from whether you can put an insert in the writer's transaction or whether the log is your only way in.
What's relaying your events in production right now, and what did the choice cost you a year later? Drop the war story in the comments.
If this was useful
Outbox and CDC are one chapter of the larger problem: the outbox is the easy part, and the traps live in what happens after. Relay lag under partition rebalance, replication slots that quietly fill a disk, the snapshot-vs-stream duplicate, sagas that have to compensate when the relay falls behind. The Event-Driven Architecture Pocket Guide is built around those patterns and the failure modes nobody warns you about until you're paged for one.

Top comments (0)