You have a service that owns the orders table. Three other teams need to know when an order changes. So you do the obvious thing: after every write, you publish an event to Kafka.
Then someone adds a new write path and forgets the publish. Now the order exists in the database but no event ever fired. The downstream read models drift. You find out three weeks later when finance asks why the numbers do not match.
This is the dual-write problem, and you cannot discipline your way out of it. Every write path is a place someone forgets to emit the event.
There is a cleaner option. Stop publishing events by hand. Let the database do it.
The database already knows what changed
Change Data Capture (CDC) turns your database into an event source. Debezium reads the write-ahead log, the same log Postgres already uses for replication and crash recovery, and turns every committed row change into a Kafka event.
Point a connector at a table. Every insert, update, and delete becomes a message on a topic. Downstream services build their own read models from that stream and never query your tables directly.
The write already happened. The WAL already recorded it. There is nothing to remember and nothing to enforce in application code, because the event is a byproduct of the commit, not a second action you have to bolt onto every write path.
Wiring it up
A Debezium Postgres connector is a bit of connector config, not application code:
{
"name": "orders-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.dbname": "shop",
"plugin.name": "pgoutput",
"table.include.list": "public.orders",
"topic.prefix": "shop"
}
}
Changes to public.orders now land on the shop.public.orders topic. Each message carries a before and after image of the row plus an op field: c for create, u for update, d for delete. A consumer decides what to do with each:
@KafkaListener(topics = "shop.public.orders")
public void onChange(ChangeEvent event) {
switch (event.op()) {
case "c", "u" -> readModel.upsert(event.after());
case "d" -> readModel.remove(event.before().id());
}
}
No coordination with the owning service. No shared library. The consumer subscribes and builds exactly the projection it needs.
The outbox pattern is CDC with intent
Tailing the whole WAL means downstream consumers see your table exactly as it is. Sometimes that is fine. Sometimes you want to publish a deliberate event, not a raw row.
The outbox pattern is the same mechanism applied on purpose. Inside the same transaction as your business write, you insert a row into an outbox table shaped like the event you want the world to see:
BEGIN;
UPDATE orders SET status = 'SHIPPED' WHERE id = 42;
INSERT INTO outbox (aggregate, event_type, payload)
VALUES ('order', 'OrderShipped', '{"orderId": 42}');
COMMIT;
Debezium tails the outbox table instead of orders. The event is atomic with the business change because they share one transaction, and its shape is something you designed rather than whatever columns the table happens to have today.
CDC is the mechanism either way. The outbox just decides what becomes an event and what stays internal.
The honest trade-off
Here is the part nobody puts on the slide.
Raw-table CDC couples your event stream to the table's physical shape. The moment a downstream consumer maps shop.public.orders, your column names are a public contract, whether you meant them to be or not.
That means column renames and other non-backwards-compatible schema changes are off the table for you. Rename status to order_status during an internal refactor and every downstream mapper breaks. Silently. There is no compile error, no failed deploy on your side. The consumer just starts reading null because nobody told it the table changed.
Tailing the raw WAL is convenient right up until the day your schema is no longer just yours.
Shape the output on purpose
The fix is not avoiding CDC. It is refusing to treat the current table shape as an accident that leaks downstream.
- Single Message Transforms (SMTs): rename, drop, or restructure fields in the connector pipeline, so an internal column rename does not change the published event.
- A CDC-facing view or outbox table: publish from a surface you designed, and let the physical table underneath change freely.
- A schema registry: enforce compatibility rules on the topic, so a breaking change fails loudly at publish time instead of silently downstream.
All three do the same thing: they put a deliberate boundary between your storage and your stream. The database can still emit every change for free. You just decide what that change looks like on the wire.
CDC is not "replication with extra steps." It is a coupling decision. Either downstream services couple to your database's physical shape, or they couple to a contract you shaped on purpose. Pick the second one.
Do you use Debezium to emit events? Do you tail the raw WAL or shape it through an outbox first, and has an internal refactor ever quietly broken a downstream consumer on you?

Top comments (0)