A transaction across services (saga)
In short: A saga is a transaction across several services as a list of steps, each with a compensating step; on failure the compensations run backwards, starting from the last completed step.
Problem
The money is taken, the headphones are gone
A customer orders the last pair of headphones for 59 € (order 1042). The payment goes through. A second later the warehouse answers: "out of stock". Another customer took the last one just before.
const payments = new Payments();const warehouse = new Warehouse({ "headphones": 0 });const delivery = new Delivery();const order = { id: 1042, item: "headphones", price: 59, city: "Varna" };try { payments.charge(order); // the payment goes through warehouse.reserve(order); // the warehouse: out of stock delivery.book(order);} catch (e) { console.log(" ✗ " + e.message);}console.log("charged to the card: " + payments.charged + " €"); ✓ charged 59 € ✗ no headphones in stockcharged to the card: 59 €The program catches the error, but the money stays charged. Tomorrow the customer writes to support, and someone refunds 59 € by hand.
In a monolith with one database the answer is a transaction: all or nothing (you saw it in the lesson on databases and in "ACID: all or nothing"). Here, though, the payment is at an external provider, and the warehouse is a separate service with its own database. There is no transaction that covers all three.
The idea
A saga: steps with reverse steps
If you cannot go back, you take new actions that repair the consequences. Each step of the order comes with a reverse step (a compensation):
| Step | Service | Reverse step |
|---|---|---|
| charges 59 € | payments | refunds 59 € |
| reserves the headphones | warehouse | releases them |
| books a courier | delivery | cancels the courier |
Each step is a small local transaction in its own service. If a step refuses, the reverse steps of the steps already done run backwards: the last step done is the first undone. This is called a saga.
A reverse step is not a rollback. A rollback erases, as if nothing had happened. A refund of 59 € is a second payment in the other direction, which the customer sees in the bank next to the first. So between the steps the system is in an intermediate state ("the order is in progress"), and everyone must cope with it.
Here the saga is led by one object, the orchestrator: it knows the steps, calls them in order, and on a failure calls the compensations. The steps are in a list and the compensations on a stack: the last one in is the first out.
See it
A saga in code
The refusal of a service is an exception. In this small system the services are objects in one process, and the refusals follow a script: there is one pair of headphones, and there is no courier to Vidin.
class StepFailed extends Error {}
const step = (action, undo) => ({ action, undo });class Warehouse { constructor(stock) { this.stock = new Map(Object.entries(stock)); }
reserve(o) { const left = this.stock.get(o.item) ?? 0; if (left === 0) throw new StepFailed("no " + o.item + " in stock"); this.stock.set(o.item, left - 1); console.log(" ✓ reserved: " + o.item); }
release(o) { this.stock.set(o.item, (this.stock.get(o.item) ?? 0) + 1); console.log(" ↩ released: " + o.item); }}JavaScript has no records and no interfaces: a step is an object with two functions, action and undo. Payments and delivery are like the warehouse: one method for the step and one for the reverse.
The orchestrator:
class Saga { constructor(steps) { this.steps = steps; }
run(order) { const undo = []; try { for (const step of this.steps) { step.action(order); undo.push(step.undo); } return "confirmed"; } catch (e) { if (!(e instanceof StepFailed)) throw e; console.log(" ✗ " + e.message); while (undo.length > 0) undo.pop()(order); return "cancelled"; } }}Step by step, in words
- Order 1043: a charger for 25 € to Vidin. Each step lives in another service, and each one has a reverse: charge ↔ refund, reserve ↔ release, book ↔ cancel.
- Step 1: Payments charges 25 €. Its reverse step goes on the undo stack (push).
- Step 2: Warehouse reserves the charger. "Release" goes on top of "refund".
- Step 3: no courier to Vidin, and Delivery refuses. The first two steps have already happened in other systems, and there is no rollback across them.
- The saga catches the refusal and turns back: it takes the compensations off the stack, top first.
- pop gives the last one in: the charger is released first. The last step done is the first undone.
- Then the payment: 25 € go back to the card. A refund is a new action, not an erased one: the customer sees both in the bank.
- Cancelled: the charger is back in stock and 0 € is kept. Every system agrees again, through later actions instead of one transaction.
Three orders: 1041 goes through, 1042 stops at the warehouse, 1043 stops at delivery.
const payments = new Payments();const warehouse = new Warehouse({ "headphones": 1, "charger": 5 });const delivery = new Delivery();const saga = new Saga([ step((o) => payments.charge(o), (o) => payments.refund(o)), step((o) => warehouse.reserve(o), (o) => warehouse.release(o)), step((o) => delivery.book(o), (o) => delivery.cancel(o)),]);const orders = [ { id: 1041, item: "headphones", price: 59, city: "Sofia" }, { id: 1042, item: "headphones", price: 59, city: "Varna" }, { id: 1043, item: "charger", price: 25, city: "Vidin" },];for (const order of orders) { console.log("order " + order.id); console.log(" = " + saga.run(order));}console.log("charged to cards: " + payments.charged + " €");order 1041 ✓ charged 59 € ✓ reserved: headphones ✓ courier to Sofia = confirmedorder 1042 ✓ charged 59 € ✗ no headphones in stock ↩ refunded 59 € = cancelledorder 1043 ✓ charged 25 € ✓ reserved: charger ✗ no courier to Vidin ↩ released: charger ↩ refunded 25 € = cancelledcharged to cards: 59 €The methods are passed with an arrow, (o) => payments.charge(o): passed on its own, payments.charge loses this. In the end 59 € are charged to cards: only for the order that went through.
Try it
Predict the output
The same saga, but the warehouse is the first step and the payment the second. The headphones are sold out. What will it print?
const payments = new Payments();const warehouse = new Warehouse({ "headphones": 0 });const delivery = new Delivery();const saga = new Saga([ step((o) => warehouse.reserve(o), (o) => warehouse.release(o)), step((o) => payments.charge(o), (o) => payments.refund(o)), step((o) => delivery.book(o), (o) => delivery.cancel(o)),]);console.log(" = " + saga.run({ id: 1042, item: "headphones", price: 59, city: "Varna" }));The warehouse refuses at the very first step, so the stack is empty: there is no money to refund. The order of the steps is an architectural decision. First come the steps that fail most often and are cheapest to undo. The payment, which costs a fee and annoys the customer, comes after them.
The idea
What cannot be undone
Not every step has a reverse. The email "your order is confirmed" cannot be sent back. A parcel the courier has already picked up does not come back with one call.
So such steps go last, after everything that can refuse. The step after which the saga only moves forward is called the pivot. After it, failures are not compensated but retried until they succeed: with timeout and retry.
Try it
Find the bug
A colleague reorders the loop, and the tests pass: on a failure the money is refunded. A week later accounting finds refunds for payments that never happened: the card was declined, and the customer got 59 €. Which line is the bug on?
Pick the line with the bug.
The reverse step goes on the stack before the step itself. If the payment refuses (the card is declined), its refund is already on the stack and gets called: the customer receives money they never paid. A compensation is recorded only after the step has succeeded.
The idea
Orchestration or choreography
The orchestrator is one place that knows the whole process. The other way has no such place: each service listens to events and answers with its own. Payments hear "order" and say "charged". The warehouse hears "charged" and says "reserved" or "out of stock". Payments hear "out of stock" and refund the money. This is called choreography.
The bus is a queue in memory with subscriptions, as in the lesson on events:
class Bus { handlers = new Map(); queue = [];
on(type, handler) { if (!this.handlers.has(type)) this.handlers.set(type, []); this.handlers.get(type).push(handler); }
publish(type, order) { this.queue.push({ type, order }); }
run() { while (this.queue.length > 0) { const { type, order } = this.queue.shift(); console.log(type + " " + order); for (const handler of this.handlers.get(type) ?? []) handler(order); } }}The same refusal of 1042, as a chain of events:
const bus = new Bus();// paymentsbus.on("OrderPlaced", (order) => bus.publish("PaymentCharged", order));bus.on("StockMissing", (order) => bus.publish("PaymentRefunded", order));// the warehouse (in this example 1042 is out of stock)bus.on("PaymentCharged", (order) => bus.publish("StockMissing", order));// ordersbus.on("PaymentRefunded", (order) => bus.publish("OrderCancelled", order));bus.publish("OrderPlaced", 1042);bus.run();OrderPlaced 1042PaymentCharged 1042StockMissing 1042PaymentRefunded 1042OrderCancelled 1042| Orchestration | Choreography | |
|---|---|---|
| Where the process is | in one object | spread across subscriptions |
| A new step | a line in the list | a new subscription and a new event |
| "Why did 1042 stop?" | the state of the orchestrator | you gather the events from every service |
| Coupling | the orchestrator knows everyone | each service knows only the events |
For two or three steps choreography is lighter. When there are five steps with conditions, the process gets lost, and the orchestrator is the clearer choice.
The idea
The compensation arrives twice
Between services, commands and events go through a queue, and the queue delivers at least once (you saw it in the lesson on events). If "refund 59 € for 1042" arrives twice, the customer gets 118 €.
So every step and every compensation is idempotent: a second time with the same input does nothing. The key is the action and the order. Towards the real provider it is sent as an idempotency key, so that the provider refuses the repeated refund too.
class Refunds { done = new Set(); total = 0;
refund(order, euros) { // the key of the step: action + order const key = "refund:" + order; if (this.done.has(key)) { console.log(" · " + order + ": already refunded"); return; } this.done.add(key); this.total += euros; console.log(" ↩ refunded " + euros + " € for " + order); }}const refunds = new Refunds();// the queue delivers "at least once": 1042 arrives twicerefunds.refund(1042, 59);refunds.refund(1042, 59);refunds.refund(1043, 25);console.log("refunded in total: " + refunds.total + " €"); ↩ refunded 59 € for 1042 · 1042: already refunded ↩ refunded 25 € for 1043refunded in total: 84 €Try it
Fill in
What checks whether the refund has already been made?
const key = "refund:" + order;
if (this.done.(key)) {
console.log(" · " + order + ": already refunded");
return;
}
this.done.add(key);has asks whether the key is already in the set, and add records it after the check. In a real service the keys are in the database, in the same transaction as the refund; otherwise a crash between the two gives a duplicate again.
The idea
When a saga hurts
A saga is not free:
- The code doubles. Every step needs a reverse, tests for both, and tests for a failure at every point.
- Intermediate states are visible. The customer sees "in progress", and the money is charged for a moment before it comes back. Someone else may read an order that is then cancelled: a saga has no isolation like a transaction.
- A failure is harder to investigate. The steps are in different services, with different logs.
So do not use it where a transaction is enough. If stock and orders are modules of one monolith with one database, a transaction keeps them safe more simply and more surely. A saga is for the steps that leave the system: an external provider, another service, another company.
More precisely, if you are curious
Why not two phases (2PC)? There is a protocol for one transaction across several databases: the coordinator asks everyone "are you ready?" and then tells everyone "commit". The external payment provider does not take part in such a protocol. And while the coordinator waits, everyone holds locked rows; if it crashes halfway, they stay locked. That is why services almost always use a saga.
The orchestrator crashes too. If the process stops after the payment, how does it know how far it got? A real orchestrator stores the state of each saga in a database (step 2 of 3, order 1042) and continues after a restart. The commands to the services go out through an outbox, so they are not lost between the write and the send.
A semantic lock. An order "in progress" is a sign to others: do not invoice it, do not allow an address change. This makes up for the missing isolation.
Ready-made tools: Temporal, Camunda and AWS Step Functions keep the state of long processes and retry the steps. There are also saga libraries in the frameworks: Axon (Java), MassTransit and NServiceBus (.NET). The queue between services in a real project is Kafka or RabbitMQ. How they are run and monitored is in the DevOps course ZeroToOps.
The name comes from a 1987 paper by Hector Garcia-Molina and Kenneth Salem on long-lived transactions in databases.
Exercise
Loyalty points
Solve it in your editor.
The shop gives 1 point for every full 10 €. Add a service Loyalty with a method for the step (gives the points) and one for the reverse (takes them away). Put it in the saga as the third step, between the warehouse and delivery. Run order 1044: a speaker for 120 € to Vidin, with 2 speakers in stock. At the end print the points and the money charged.
order 1044 ✓ charged 120 € ✓ reserved: speaker ✓ +12 points ✗ no courier to Vidin ↩ -12 points ↩ released: speaker ↩ refunded 120 € = cancelledpoints: 0, charged: 0 €More tasks
- A declined card. Make the payment refuse above 100 € ("card declined") and run 1044 to Sofia. What is on the stack when the payment refuses?
- Choreography. Add the events
StockReservedandOrderConfirmedto the bus and run an order that is in stock.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Transactional outbox · API design: contract, versions, retried requests · Trade-offs · When the neighbour is slow: timeout, retry, circuit breaker
- Continues in: Integrating old and foreign systems · Changing a live system (strangler fig, expand–contract) · Your first architecture task
- Tasks on this topic: The architect at work