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.
BenefitTrade-off
Guarantees an event is sent if the transaction succeeds — no lost eventsAdds an outbox table and a publisher component
No distributed transaction between the database and the message brokerAdds a little latency — the event isn’t sent synchronously
Works with any message brokerThe outbox table needs regular cleanup
A reliable foundation for event-driven architectureYou still have to choose a mechanism — polling publisher or transaction log tailing

An outbox that mixes concerns — many event types in one outbox with no routing. Symptoms:

  • The publisher has to know which topic each event type goes to.
  • The outbox becomes a god table where everything is mixed together.
  • Split the outbox by aggregate root or by topic.

No reliable publisher — the publisher crashes with no retry mechanism. Symptoms:

  • Events sit in the outbox for a long time — consumers never get them.
  • The publisher needs a retry mechanism and dead-letter handling.

💡 Real-world examples

Eventuate (Chris Richardson): Chris Richardson, author of “Microservices Patterns” and the person who cataloged Transactional Outbox as part of the microservices pattern language, built Eventuate as a framework that implements the pattern directly (microservices.io: Transactional outbox).

AWS Prescriptive Guidance: documents Transactional Outbox as one of its officially recommended cloud design patterns for solving the dual-write problem between a database and a message broker (AWS Prescriptive Guidance: Transactional outbox pattern).

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?