Sagas, compensations and the outbox pattern
A business operation spanning service-owned databases can not use one ACID transaction, and 2PC is a fragile SPOF. Sagas chain local transactions with compensations; the transactional outbox commits state and event in one DB txn so a crash never loses the event.
The order service did the obvious thing. Inside createOrder it called await this.orders.save(order) and, on the very next line, this.client.emit('order.created', payload). Code review passed it — two lines, both green in tests, ships clean. Then a routine deploy rolled the pods, and one request happened to be sitting exactly between those two lines when its pod got SIGTERM. The order row committed to Postgres. The order.created event never reached the broker. Nothing threw, nothing logged an error — the request just vanished mid-flight. The order existed; inventory never reserved it; payment never charged it. The customer saw “order placed” and then silence, and three days later a support ticket asked where their thing was. There was no bug in either line. The bug was the gap between them — two systems, no shared transaction. This lesson is about closing that gap without reaching for a distributed transaction.
Why not one transaction: 2PC is a fragile SPOF
A “place order” operation touches three services — order, inventory, payment — and each owns its own database. The instinct from monolith days is to wrap all three writes in one transaction so they commit or roll back together. You can’t. An ACID transaction lives inside a single database; it cannot span three independent databases owned by three independent services.
The classic answer to “atomic across multiple databases” is two-phase commit (2PC / XA): a transaction coordinator asks every participant to prepare, and once all vote yes, tells them all to commit. It does give you atomicity — and almost every modern microservice stack refuses to use it, for three reasons that compound under load:
- The coordinator is a single point of failure. If it dies after participants have voted to prepare but before it broadcasts commit, every participant is stuck holding locks, waiting for a decision that may never come.
- Locks are held across the network. Between prepare and commit, each database holds its locks for the full round-trip to every other participant. Under partition or slowness, those locks sit there, and throughput collapses.
- Availability tanks under partition. 2PC is a CP protocol in the brutal sense: if any participant or the coordinator is unreachable, the whole transaction blocks. One slow service stalls all of them.
So the consistency model changes. Instead of one transaction that is atomic now, you accept eventual consistency: the steps happen one at a time, each atomic on its own, and the system converges to a correct state shortly after — not instantly. The pattern that does this is the saga.
The saga: local transactions plus compensations
A saga is a sequence of local transactions, each one running inside a single service’s own database, linked by events or commands. Reserve inventory is one local transaction in the inventory DB. Charge payment is another local transaction in the payment DB. Each commits on its own. The saga is the chain.
The hard part is failure. If step 3 (charge payment) fails, you can’t roll back steps 1 and 2 — they already committed. A committed local transaction is gone; there is no ROLLBACK to issue. Instead the saga runs compensating transactions: a new local transaction that semantically undoes the prior one. You don’t un-charge a payment; you issue a refund. You don’t un-reserve inventory; you release the reservation.
// A saga step is a local txn + a named compensation. The orchestrator runs the
// forward action; if a LATER step fails, it runs each completed step's compensate().
interface SagaStep {
action: () => Promise<void>; // local transaction in ONE service
compensate: () => Promise<void>; // semantic undo, runs AFTER commit, may itself fail
}
const placeOrderSaga: SagaStep[] = [
{ action: () => inventory.reserve(orderId, items),
compensate: () => inventory.release(orderId) }, // not a rollback — a release
{ action: () => payment.charge(orderId, total),
compensate: () => payment.refund(orderId) }, // not a rollback — a refund
];There are two ways to coordinate the chain, and choosing between them is a senior decision:
- Choreography — no central coordinator. Each service listens for events and emits the next one. Order service emits
order.created; inventory reacts, reserves, emitsinventory.reserved; payment reacts to that. Decentralized and simple for short flows — but the overall flow exists nowhere in code. To answer “what happens when an order is placed” you read five services’ event handlers and reconstruct it in your head. It rots as the flow grows. - Orchestration — a central saga orchestrator owns the flow. It sends explicit commands (
reserveInventory, thenchargePayment), tracks the saga’s state in its own table, and decides when to fire compensations. It’s another component to build and run, but the flow is in one place: explicit, observable, testable, restartable.
The senior rule of thumb: choreography for short 2–3 step flows; orchestration when the flow is long, branchy, or needs visibility — anything you’ll have to debug at 3am wants a state machine you can query.
Together these two styles cover the coordination spectrum: choreography gives you zero new components and zero centralized state, while orchestration gives you one place to answer “where is this saga right now?” Without that single place, a failed payment in a five-service choreography requires you to read five event logs to reconstruct what happened.
▸Why this works
Why doesn’t wrapping save(order) and client.emit(...) in a single try/catch make the dual write safe? Because the two calls target two different systems — your database and your message broker — and there is no transaction boundary that spans both. A try/catch only reacts to an exception; it cannot make two independent commits succeed-or-fail together. A crash (a SIGTERM, an OOM kill, a power loss) doesn’t throw anything catchable, and it can land between the two calls in either order: DB-committed-but-event-not-sent (the event is lost), or event-sent-but-DB-write-failed (you published a lie). To make the state and the event atomic you have to put them in the same database transaction — and an event in the broker can’t be in a database transaction. The only move is to co-locate the event with the state as a row in your own DB (the outbox), commit them together, and let a separate relay carry that row to the broker afterward.
The dual-write problem and the transactional outbox
The Hook’s incident is the dual-write problem, and it’s the real reason this lesson exists. A service almost always has to do two writes as part of one logical step: update its own database (the order row) and publish an event so other services learn about it (order.created). Those are two systems — the DB and the broker — with no shared transaction. Whatever order you do them in, a crash in the gap breaks you:
- DB first, then publish, crash in between → the order exists but no event was sent. Downstream services never learn the order exists. Inventory never reserves; the customer waits forever. This is the Hook.
- Publish first, then write DB, DB fails → you published
order.createdfor an order that doesn’t exist. Now downstream services act on a lie, and you have a phantom order rippling through the system.
The transactional outbox fixes this by removing the second system from the critical write. In the same local database transaction that writes the business row, you also INSERT the event into an outbox table. Both rows commit together or neither does — it’s one transaction in one database, so there is no gap:
// Both writes in ONE local transaction: business row + event row commit atomically.
await this.dataSource.transaction(async (manager) => {
const order = await manager.getRepository(Order).save(newOrder);
await manager.getRepository(OutboxEvent).insert({
aggregateId: order.id,
type: 'order.created',
payload: JSON.stringify({ orderId: order.id, items: order.items }),
// status defaults to 'pending'; a relay flips it to 'sent' after publishing
});
}); // <- if the pod dies here, NEITHER row committed. No half state. No lost event.A separate message relay then carries outbox rows to the broker. Two common implementations:
- Polling publisher — a background worker
SELECTspendingrows, publishes each to the broker, and marks itsent(or deletes it). Simple, no extra infrastructure; the trade-off is poll latency and load. - Change Data Capture (CDC) — a tool like Debezium tails the database’s write-ahead log, sees the outbox
INSERT, and publishes it. Near-real-time and no polling, but it’s a serious piece of infrastructure to operate.
Either way the relay guarantees at-least-once publication: it keeps trying until the broker acknowledges, so a publish failure or relay crash means a retry, never a lost event. At-least-once means a consumer can see the same event twice — which is exactly where the idempotent consumers from the previous lesson (L03) earn their keep: dedupe on the event id downstream, and at-least-once delivery plus idempotent processing gives you effectively-once. The outbox doesn’t replace delivery semantics; it feeds them a reliable source.
// The relay: at-least-once publish. Keeps the event until the broker acks.
async function relayTick(manager: EntityManager, client: ClientProxy) {
const pending = await manager.getRepository(OutboxEvent)
.find({ where: { status: 'pending' }, take: 100, order: { id: 'ASC' } });
for (const evt of pending) {
await firstValueFrom(client.emit(evt.type, JSON.parse(evt.payload))); // may retry
await manager.getRepository(OutboxEvent).update(evt.id, { status: 'sent' });
}
}Placing an order spans the order, inventory, and payment services, each with its own database, and must stay consistent end to end including reliably publishing the order.created event. How do you design it?
Why use a saga with compensating transactions instead of a single distributed transaction across the order, inventory, and payment services?
A service does save(order) and then client.emit('order.created'), and the pod is killed between the two calls. Which pattern prevents the lost event, and how?
- 01Why can't a place-order operation across the order, inventory, and payment services use one transaction, why is 2PC avoided, and what replaces it?
- 02Explain the saga's compensations, choreography vs orchestration, and how the transactional outbox solves the dual-write problem.
A business operation spanning service-owned databases can’t be one ACID transaction — a transaction lives in a single database — and the cross-database alternative, 2PC/XA, is avoided because its coordinator is a single point of failure, it holds locks across the network between prepare and commit, and it blocks under partition. The replacement is a saga: a sequence of local transactions, each atomic in one service and linked by events or commands, accepting eventual consistency. Because a committed local transaction can’t be rolled back, a later failure triggers compensating transactions — a refund, not an un-charge; a release, not an un-reserve — which run after commit, have no isolation, and may fail themselves. Coordinate by choreography (no coordinator, services react to events; good for 2-3 steps, but the flow lives nowhere) for short flows, or orchestration (a central orchestrator with explicit commands and queryable state) when the flow is long, branchy, or needs visibility. The dual-write problem is the core trap: a step must commit its DB row and publish an event across two systems with no shared transaction, so a crash in the gap loses the event (the Hook) or publishes a lie. The transactional outbox fixes it by inserting the event into an outbox table in the same local transaction as the business row — they commit together or not at all — after which a relay (polling publisher or CDC like Debezium) carries outbox rows to the broker at-least-once. Combined with idempotent consumers from L03 that dedupe on the event id, at-least-once publication plus idempotent processing yields effectively-once delivery, with no write ever escaping a transaction and no event ever lost to a crash. Now when you see a “customer sees ‘order placed’ but nothing happens” ticket, you know exactly where to look: a dual-write gap with no outbox, or a saga step that never ran its compensation.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.