Your "Row Changed" Hook Isn't CDC: The Dual-Write Trap
You add a feature: whenever an order is saved, publish an order.updated event so other services react. You commit the row, then publish the event. It works in every test. Then one day a process dies in the half-second between those two lines, and an order changes with no event to prove it. You did not build change data capture. You built a lossy notifier, and the gap only shows up under the load where it hurts most.
The trap: two writes, one promise
The shape is always the same. You write to two systems that cannot commit together, usually a database and a message broker, and you want both or neither. But there is no shared transaction across them. So your code does one, then the other:
db.commit(order) // 1. durable
broker.publish(event) // 2. separate system, separate failure
Now walk the failure window. The process crashes after line 1 and before line 2: the order is saved, the event never fires, downstream services never learn. Flip the order and publish first: now a crash after the publish and before the commit fires an event for a change that never became durable, and consumers act on a fact that does not exist. Either ordering leaves a window where the two systems disagree.
This is the dual-write problem, and it is not a bug you can retry your way out of. Two independent writes cannot be made atomic by careful sequencing. The only fixes move the problem somewhere a single transaction can cover it.
Why this is not CDC
The reason this surprises people is that it looks like change data capture, and CDC does not have this gap. The difference is where the events come from.
Real CDC reads the database's own commit log, the write-ahead log the database already keeps to be durable. Every committed change is in that log by definition, and nothing that was rolled back is. A CDC reader tails the log and emits exactly the set of changes the database actually committed, after the fact, with no second write to lose.
An application-level row-changed hook is a different animal wearing similar clothes. It only sees writes that went through your code, so a change made by a migration, a manual query, or another service is invisible to it. And it fires as a separate step after your commit, which is the exact dual-write window above. Same words on the tin, different guarantee inside. If you need every change and you need it reliably, you want the log, not the hook.
The guarantee is not in the feature's name. It is in whether the event shares a transaction with the change.
The fix: the outbox pattern
The standard fix keeps both writes but puts them in the same transaction. Instead of publishing to the broker inside your request, you insert the event into an outbox table in the same database, in the same transaction as the business change:
BEGIN
UPDATE orders SET ... WHERE id = ?
+ INSERT INTO outbox (event, payload) VALUES ('order.updated', ?)
COMMIT
Now the change and its event commit atomically. They can never disagree, because they are one write. A separate relay process reads unsent rows from the outbox, publishes them to the broker, and marks them sent. If the relay crashes mid-publish, it retries on restart from where it left off. That gives you at-least-once delivery: every committed change produces its event, possibly more than once, never zero times. You have moved the unavoidable duplication to a place you can handle it, and removed the loss entirely.
If you would rather not run a relay, real CDC is the other honest answer: point a log reader at the database and let it emit the changes. Both share the same principle. The event must originate from something that committed with the data, either the same transaction or the same log.
Then make consumers idempotent
At-least-once shifts the burden to the reader, and that is the right place for it. Since a consumer may see the same event twice, processing it twice must equal processing it once. The usual mechanism is an idempotency key: give each event a stable id, record the ids you have handled, and skip one you have already applied. Deriving the id deterministically from the change, rather than generating a fresh one per attempt, is what makes a redelivery a safe no-op instead of a duplicate side effect.
This is the quiet truth under most messaging systems. Exactly-once delivery is not available across a network, so you build exactly-once effect instead: at-least-once delivery plus idempotent handlers. Chasing true exactly-once delivery is how teams waste a quarter reinventing a guarantee the network cannot give them.
When best-effort is fine
Not every notification deserves an outbox. If losing an occasional event is harmless, a cache nudge, a best-effort metric, a UI hint that the next poll will correct, then fire-and-forget is the simpler, cheaper choice and the added machinery is waste. The mistake is not using best-effort delivery. The mistake is using it by accident, for events that another service treats as the source of truth.
So make it a decision, not a default. For each event, ask what breaks if it is silently dropped once. If the answer is nothing, publish and move on. If the answer is a customer sees a wrong balance or an order ships twice, that event shares a transaction with its data or it is not reliable, whatever the feature that emits it is called.
FAQ
What is the dual-write problem? Writing to two systems that cannot commit together, usually a database and a broker. If the process crashes between the two writes they disagree. Careful sequencing cannot make two independent writes atomic.
Is a database row-changed hook the same as CDC? No. CDC reads the database commit log, so it sees every committed change and nothing else. An application-level hook only sees writes through your code and fires after the commit as a separate step, so a crash in between loses the event.
How does the outbox pattern fix it? You insert the event into an outbox table in the same transaction as the business change, so they commit atomically. A relay reads the outbox and publishes at-least-once, marking rows sent. Consumers stay idempotent to absorb duplicates.