← blog

Change Data Capture — the Database Already Wrote It Down

Every database keeps a crash-recovery journal of every change it commits. CDC taps that journal and turns your commits into events — no polling, no second write, no dual-write problem.

Here’s a thing worth knowing about every serious database: it already journals every change, before applying it. Postgres calls it the write-ahead log (WAL), MySQL calls it the binlog. It exists for crash recovery — power dies mid-write, the database restarts, replays its journal, and comes back consistent. Think of it as the flight recorder: nothing the database does is ever not in there, because being in there first is what makes the write real.

Now the observation that turns a recovery mechanism into an architecture: if the journal already contains every committed change, in order, guaranteed — why are we writing events anywhere else? Stop asking the application to announce what it did. Read the flight recorder. That question is CDC — change data capture — the second, more general fix for the dual-write problem: where the outbox adds a table and a polling relay, CDC skips both.

The whole mechanism: a connector tails the database’s own change journal and turns every committed change into an event on the broker — the application just writes to its database, and the announcement happens because the write happened.

Almost no code, on purpose#

There’s almost no application code to show, and that’s the point — markPaid shrinks back to the naive version, minus the fatal line:

@Transactional
public void markPaid(Order order) {
    order.pay();
    orders.save(order);   // this IS the announcement now
}

The machinery lives outside: Debezium (the famous open-source CDC tool) runs a connector that reads the WAL and writes every change to a Kafka topic, typically within milliseconds:

sequenceDiagram
  participant A as markPaid()
  participant DB as database
  participant W as WAL (the journal)
  participant D as Debezium
  participant B as broker
  A->>DB: UPDATE orders SET status = PAID
  DB->>W: journal the change, then commit
  DB-->>A: committed
  D->>W: reading, always
  W-->>D: orders #4211: PLACED to PAID
  D->>B: publish change event
  Note over B: warehouse, email, search all hear it
Nobody announces anything. The write itself is the announcement, because writing and journaling are the same act — Debezium just reads the journal out loud.

The dual-write problem doesn’t get solved here so much as dissolved: there’s only one write in the whole story. The journal entry isn’t a second write we added — it’s an internal step of the first one, inside the database’s own transaction machinery. The gap between “saved” and “announced” can’t open, because they’re the same event in the same journal.

And this is the pattern’s superpower over the outbox — it isn’t tied to events you remembered to emit. Every table, every change, every writer — an old batch job, a manual UPDATE in a migration, a second service touching the same DB — all of it lands in the journal, so all of it can flow out:

appdatabaseWAL: every changeDebeziumbrokersearchcacheanalytics
One write, many followers — and none of them asked the application for anything. This is why CDC outgrows the dual-write fix: the same stream that feeds the broker rebuilds the search index, warms the cache, and fills the warehouse.

The catch: rows are not intentions#

A change event out of the WAL looks like this — the row’s before and after, not your domain language:

{
  "op": "u",
  "before": { "id": 4211, "status": "PLACED" },
  "after":  { "id": 4211, "status": "PAID" },
  "source": { "table": "orders", "lsn": 949472 }
}

That’s honest but low-level: consumers learn that a column changed, not that a customer paid. Your table schema quietly becomes a public API — rename a column and you’ve broken subscribers you’ve never met. Two ways out:

  • Keep raw CDC for data plumbing — search-index sync, cache invalidation, replicating into the warehouse. Consumers there genuinely want rows.
  • For domain events, combine the patterns: write an outbox row with a real event (OrderPaid, exactly as designed), and let Debezium tail the outbox table instead of a polling relay. This is such a common marriage that Debezium ships a dedicated outbox event router for it — intention-shaped events, journal-grade delivery, no polling.

Delivery is at-least-once here too (Debezium resumes from its last checkpoint after a crash and can replay a little), so the consumer-side elevator-button rule — safe to receive twice — never goes away.

Once you see it, it’s everywhere#

  • Debezium over Postgres WAL / MySQL binlog — the de-facto standard; runs on Kafka Connect.
  • Managed flavors everywhere: DynamoDB Streams, MongoDB change streams, SQL Server’s built-in CDC — every serious datastore now exposes its journal, because everyone kept asking.
  • Database replicas are CDC, and always were: a read replica is just a consumer replaying the leader’s journal. CDC to Kafka is the same river with more mouths.
  • Search-index sync at nearly every company with Postgres + Elasticsearch — the “why is search out of sync” bug from the dual-write post is usually fixed exactly this way.

When NOT to use it#

  • You just need one service’s domain events and don’t run Kafka. A polling outbox is a table and a loop; Debezium is a connector cluster to deploy, monitor, and upgrade. Don’t buy a freight train for one parcel.
  • You need intention-rich events and won’t do the outbox-router combo. Raw row diffs as your public event API couples every consumer to your schema — that bill arrives with the first migration.
  • Your database’s journal isn’t reachable — some managed databases restrict WAL/binlog access or make replication slots painful. Check before designing around it.

Comments

Signed in with GitHub. Be kind.