Transactional outbox
The problem: dual writes
Publishing an event after a database write is a dual write. A crash, a broker outage or a rollback between the two writes either loses the event or announces a change that never happened. In-process emitters lose events on restart, and queue libraries on Redis cannot join your SQL transaction.
The pattern
The transactional outbox writes the event to a table in the same database transaction as the business data. A separate relay reads the table after commit and delivers the events. The database transaction is the single source of truth: commit keeps both, rollback discards both.
How relaybus implements it
publishwrites the event and one delivery per subscribed handler or route into the active transaction. Events with a key get a per-key sequence that follows commit order.- The dispatcher in every process claims due deliveries with a lease, runs the handler, renews the lease while it runs and marks the delivery completed.
- A failure reschedules the delivery with backoff, or dead-letters it after the last attempt. A worker that crashes leaves an expired lease, and another worker takes the delivery over.
- Routes forward events to Kafka or RabbitMQ after commit, so a broker outage only delays them. Subscriptions store received events by id before acknowledging them, which makes consumption idempotent (the inbox pattern).
Which transaction does publish join?
publish finds the transaction in this order: the transaction option, the transaction opened by bus.transaction(), then your transactionResolver (for example from @nestjs-cls/transactional). With none, it throws TransactionRequiredError unless you opt into transactionMode: 'optional', where the event stays durable but is no longer atomic with your writes.
await bus.transaction(async (tx) => {
await tx.query('INSERT INTO orders ...');
await bus.publish(new OrderPlaced({ orderId }), { key: orderId });
});