“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.
-- 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)// 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).