68

Dual-write problem → transactional outbox

#
  • pattern
  • established
  • fragment

Conflicts with: Outbox ↔ write latency

“Wrote to the database and published to Kafka” is two systems with no shared transaction: you can always crash between them, and one side will lie. There are two correct schemes: outbox (the event is written in the same DB transaction, a relay / CDC delivers it) or CDC directly from the WAL. “Publish first, then commit” and the reverse are both broken — they just produce phantoms of opposite sign.

Code example (fragment)
fragment
SQL
-- one transaction: the business fact and the event are atomic
BEGIN;
INSERT INTO payments (id, amount_cents, status) VALUES ($1, $2, 'captured');
INSERT INTO outbox (event_id, aggregate_id, type, payload)
VALUES (gen_random_uuid(), $1, 'PaymentCaptured', $3);
COMMIT;

-- relay (Debezium / poller) reads outbox → Kafka; delivery is at-least-once,
-- so consumer-side dedup by event_id is mandatory (see 21)
Go · relay
// at-least-once loop: mark only after a confirmed publish
rows := tx.Query(`SELECT event_id, payload FROM outbox
                  WHERE published_at IS NULL ORDER BY seq LIMIT 100 FOR UPDATE SKIP LOCKED`)

for _, e := range rows {
    if err := producer.Send(ctx, e); err != nil { break } // not marked — we'll come back
    tx.Exec(`UPDATE outbox SET published_at = now() WHERE event_id = $1`, e.ID)
}

Outbox does not give exactly-once — it gives atomic writes and at-least-once delivery; deduplication still happens at the end (21). Listen-to-yourself and “just retry the publish” are not equivalents but well-known ways to get out of sync.

The relay loop is not idempotent by itself — it is at-least-once: the mark is set only after a confirmed publish, so a crash between send and update yields redelivery, not loss. Hence the mandatory details: claiming rows with FOR UPDATE SKIP LOCKED, a bounded batch, a publish timeout, and deduplication at the consumer (21).

Last technical review: 2026-08-04 · Reference · 112 principles