Skip to content

Transactional Outbox

Your service needs to record a business change and tell the rest of the system about it. As the module intro showed, doing both as two independent writes — one to the database, one to the broker — opens a gap where a crash produces either a ghost event or a lost event. You want a way for the message to share the fate of the business change: if the change commits, the message will eventually be delivered; if the change rolls back, no message exists.

You cannot enroll the message broker in your database transaction, and two-phase commit across them is something you have already ruled out. But you can control your own database completely. Inside a single local transaction you can write as many rows, to as many tables, as you like, and they all commit or roll back together.

So the forces are: you need atomicity between “the business change happened” and “a message about it exists,” you only have one system that gives you transactions (your database), and you still need the message to leave the database and reach the broker eventually.

Add an outbox table to the service’s database. When you make a business change, write the outgoing message as a row in the outbox table within the same transaction as the change. Because both inserts are in one local transaction, they commit together or not at all — the dual write has collapsed into a single atomic write.

The outbox row is not the delivery; it is a durable intent to deliver. A separate component, the message relay, reads committed outbox rows and publishes them to the broker, marking each as sent (or deleting it) once the broker acknowledges. How the relay reads the outbox is the subject of the next two lessons — by tailing the transaction log, or by polling the table.

flowchart LR
  subgraph Service
    H[Handler]
  end
  subgraph DB[Service Database]
    BT[(business table)]
    OT[(outbox table)]
  end
  R[Message Relay]
  B[(Message Broker)]
  H -->|one local transaction| BT
  H -->|same transaction| OT
  R -->|read committed rows| OT
  R -->|publish, then mark sent| B
The business row and the outbox row commit in one transaction; a relay drains the outbox to the broker

The core move is a single transaction that inserts the business row and an outbox row together. The serialized payload and message metadata go into the outbox; nothing is published from this code path at all.

import { randomUUID } from 'node:crypto';
// pool is a node-postgres Pool; the whole function is one DB transaction.
async function confirmOrder(orderId: string, customerId: string, amount: number) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
'UPDATE orders SET status = $1 WHERE id = $2',
['CONFIRMED', orderId],
);
const payload = JSON.stringify({ orderId, customerId, amount });
await client.query(
`INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload)
VALUES ($1, $2, $3, $4, $5)`,
[randomUUID(), 'Order', orderId, 'OrderConfirmed', payload],
);
await client.query('COMMIT'); // business row + outbox row commit together
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}

What you gain:

  • Atomic publish-intent. The message and the business change share one transaction, so you can never have one without the other. The dual-write problem is solved at the source.
  • Broker independence. The write path no longer touches the broker, so a broker outage cannot fail or slow down a business operation — the outbox simply accumulates rows until the relay catches up.
  • A natural audit trail. The outbox is a durable record of every event the service intended to emit.

What it costs you:

  • At-least-once delivery, not exactly-once. The relay can crash after publishing but before marking the row sent, so it will republish. Consumers must therefore be idempotent — the final lesson of this module.
  • A relay to build and run. Someone has to drain the outbox; the next two lessons cover the two ways to do it.
  • Outbox housekeeping. Sent rows must be pruned, or the table grows without bound.
What makes the transactional outbox atomic with the business change?
What does a committed outbox row represent?
Why must consumers of outbox-delivered messages be idempotent?