Transactional outbox0/59 done
Part 3 · System design · Reliability

Transactional outbox

Saves a row and the event about it in one database transaction, then publishes the event. No lost events, and no events for rows that rolled back.

How it works

The bug it fixes: save the order, then publish to Kafka. If the process dies between the two, the order exists and nobody hears about it. Publish first instead, and a failed insert leaves an event for an order that doesn't exist. So insert the event into an outbox table in the same transaction as the order. A relay (a poller, or change data capture like Debezium reading the write ahead log) publishes unsent rows and marks them sent. The relay can die after publishing and before marking, so it publishes again: delivery is at least once. Consumers record processed event ids and skip repeats, which makes the effect exactly once.

write pathone local transaction, no two phase commit
deliveryat least once
effectexactly once, if consumers dedupe
delaythe poll interval, or near real time with change data capture

Reach for it when

Implementation (JavaScript, tested)
// The row and its event commit in ONE transaction. A relay publishes later. Consumers dedupe by event id.
function placeOrder(db, order) {
  db.transaction(tx => {
    tx.orders.push(order);
    tx.outbox.push({ id: `evt-${order.id}`, type: "order.placed", payload: order, sent: false });
  });
}
// May die after publish and before marking sent, so delivery is at least once.
function relay(db, publish) {
  for (const e of db.outbox)
    if (!e.sent) {
      publish(e);
      e.sent = true;
    }
}
class DedupConsumer {
  constructor(apply) {
    this.seen = new Set(); // production: a processed_events table, written in the same transaction as apply
    this.apply = apply;
  }
  handle(e) {
    if (this.seen.has(e.id)) return false;
    this.apply(e);
    this.seen.add(e.id);
    return true;
  }
}
// tiny in-memory "database" with rollback, so the pattern is testable
function memDb() {
  const db = { orders: [], outbox: [] };
  db.transaction = fn => {
    const snap = { orders: [...db.orders], outbox: [...db.outbox] };
    try {
      fn(db);
    } catch (err) {
      db.orders = snap.orders;
      db.outbox = snap.outbox;
      throw err;
    }
  };
  return db;
}

Traps

In real systems: Debezium has an outbox event router. Many teams run a plain poller with SELECT ... FOR UPDATE SKIP LOCKED so several relays can share the work.
Say out loud: I don't dual write. The event goes into an outbox table in the same transaction, a relay publishes it at least once, and consumers dedupe on the event id, so the effect happens once.