async function placeOrder(dto: OrderDto) {
const order = await db.orders.insert(dto);
await broker.publish("order.created", { id: order.id });
return order;
}
The problem is a dual write: the code writes to two separate systems, the database and the message broker, with no shared transaction. A transaction is a group of writes that all succeed or all fail together.
Two things can go wrong:
- You insert first, then the process crashes or the broker is down. The order exists, but no event is sent.
- You publish first, then the insert fails. You sent an event for an order that was never saved.
The transactional outbox fixes this. The service inserts the order and an outbox row that describes the event in the same local database transaction. So both rows are saved, or neither is. A separate process, the relay, then publishes the outbox rows to the broker and marks them as sent. The relay can find new rows in two ways:
- It polls the table on a timer.
- It uses change data capture (CDC: reading changes from the database's own log), for example with Debezium.
The relay can crash after it publishes but before it marks the row as sent. Then it publishes the same event again. So delivery is at-least-once, and consumers must be idempotent: handling the same event twice must have the same effect as handling it once.
- The database write and the publish are not atomic, so one can happen without the other.
- Write the business row and the outbox row in one transaction.
- A relay publishes the outbox rows by polling the table or by using CDC.
- Delivery is at-least-once, so consumers must be idempotent.