The problem: one action, four systems
Snip's paid plan adds custom domains: an owner can use go.shop.example/summer instead of snip.link/x7Kp.
Setting one up touches four systems:
- Charge the domain add-on to the owner's card, through a payment provider.
- Record the domain in Snip's database as pending.
- Get a TLS certificate for it from a certificate authority, which only works once the owner's DNS points at Snip.
- Configure the CDN to serve the domain, then mark it active.
If step 3 fails because the owner hasn't changed their DNS, the card has been charged for a domain that doesn't work. In one database, a transaction would roll everything back. But the payment provider, the certificate authority and the CDN aren't in Snip's database, and none of them will join a transaction that spans the others.
Sagas
A saga is the answer from a 1987 paper by Hector Garcia-Molina and Kenneth Salem, written for long-running database transactions and now used across services. A long transaction is a saga if it can be written as a sequence of smaller transactions, each committed on its own, where the system guarantees that either all of them complete, or compensating transactions run to undo the ones that did.
For Snip's custom domain:
| Step | Compensation if a later step fails |
|---|---|
| Charge the card | Refund the charge |
| Record the domain as pending | Mark it failed |
| Issue the TLS certificate | Revoke it |
| Configure the CDN | Remove the configuration |
| Mark the domain active | (last step: nothing after it can fail) |
5 messages. Use Next and Back to follow them in order.
Text version
- Domain saga → Payments: charge £5 — Step 1 commits at the payment provider.
- Domain saga → Snip DB: domain: pending — Step 2 commits in Snip's database.
- Domain saga → Certificate authority: issue certificate (fails) — The certificate authority checks the domain, finds DNS still points elsewhere, and refuses. Retries won't help until the owner acts.
- Domain saga → Snip DB: domain: failed — Compensations run in reverse order. The domain is marked failed, with the reason for the owner.
- Domain saga → Payments: refund £5 — The charge is refunded. The owner sees a charge and a refund, not nothing: compensation is a new action, not time travel.
Compensation isn't rollback
A database rollback makes it as if the transaction never happened. A compensation is a new action that undoes the business effect, and the world can see both:
- Others may have seen the in-between state. Between steps 2 and 5, the dashboard showed "pending". That's a real state the product must design for.
- Some things can't be undone. An email can't be unsent; send it only after the last step that can fail, or send a follow-up instead. Order the steps so the hard-to-undo ones come last.
- Compensations can fail too. A refund call can time out. Compensations must be retried until they succeed, so, like every step, they must be safe to repeat (07.03 Delivery guarantees and idempotent consumers): refund with an idempotency key, so a retried refund doesn't refund twice.
Who drives the steps?
Orchestration. One component, the orchestrator, knows the steps, calls each service, records progress, and runs the compensations on failure. The whole process is in one place, readable and easy to monitor.
Choreography. No one is in charge. Payments publishes charge.succeeded; the domain service hears it and records
the domain, then publishes domain.recorded; the certificate service hears that, and so on. Each service reacts to
events (07.06 The outbox pattern and change data capture). It's loosely coupled, but the process exists only as the sum of many subscriptions, which
makes it hard to see, change, or answer "where is this order stuck?".
A rough rule: choreography for simple reactions with two or three steps, orchestration once there's a real process with branches, waits and compensations. Snip's domain setup has a wait (for DNS that may take a day), so it's orchestrated.
type Step<C> = { name: string; run: (ctx: C) => Promise<void>; undo?: (ctx: C) => Promise<void> };
/** Run steps in order; if one fails, undo the completed ones in reverse, then rethrow. */
export async function runSaga<C>(steps: Step<C>[], ctx: C, record: (event: string) => Promise<void>) {
const completed: Step<C>[] = [];
for (const step of steps) {
try {
await step.run(ctx);
await record(`done: ${step.name}`);
completed.push(step);
} catch (err) {
await record(`failed: ${step.name} (${(err as Error).message})`);
for (const done of completed.reverse()) {
if (done.undo) {
await done.undo(ctx); // must be retried until it succeeds, and safe to repeat
await record(`undone: ${done.name}`);
}
}
throw err;
}
}
}Run with a certificate step that throws, it records done: charge card, done: reserve domain, failed: issue certificate, undone: reserve domain, undone: charge card, and leaves nothing charged or reserved. This sketch
shows the shape, but it keeps its progress in memory: if the process dies between steps, nobody knows to carry on or
compensate. A real orchestrator records each step durably (the record function writing to a table, in the same
transaction as the step where possible) and resumes from that record after a crash, and it can wait for a day for DNS
without holding anything open.
Durable workflow engines
That durable record-and-resume is hard to get right, so there are engines for it: Temporal, AWS Step Functions, Netflix's Conductor and others. In Temporal's model you write the workflow as ordinary code; every step's result is saved to an event history, and if the worker running it dies, another worker fetches the history and replays the code against it to rebuild its state and carry on. The price is that workflow code must be deterministic (no reading the clock or random numbers directly), because it's re-run during replay. Steps that talk to the outside world, called activities, are retried with backoff.
For a single process like Snip's domain setup, a jobs table and a saga table with each step's status (07.05 A job queue on Postgres with SKIP LOCKED) are enough. When a company has dozens of long processes with waits and branches, an engine pays for itself.
Seen in production
Princeton University
1987Sagas
The paper starts from long-lived transactions, such as producing monthly account statements at a bank or processing claims at an insurance company, which hold database resources for a long time and delay shorter transactions. It proposes that a long-lived transaction is a saga if it can be written as a sequence of transactions that can be interleaved with others, with the database guaranteeing that either all of them complete or compensating transactions are run to amend a partial execution.
What we foundBreak a long transaction into a sequence of committed steps, and guarantee that either all complete or compensations amend the partial result.
Sagas (Hector Garcia-Molina and Kenneth Salem, ACM SIGMOD 1987)cs.cornell.edu ↗Temporal
DocsDurable execution
Temporal describes a workflow that, once started, runs to completion whether that takes a second or a year. If the process running it fails, a new worker fetches the event history and replays the workflow code against it to rebuild local state, which is why workflow definitions must be deterministic. A failed activity is retried with backoff, and one whose worker dies is rescheduled.
What we foundRecord every step, and recover from a crash by replaying the workflow code against that record.
Understanding Temporal (Temporal documentation)docs.temporal.io ↗In Snip's domain saga, the 'email the owner: your domain is live' step runs second, right after the charge. Then the certificate step fails. What's the problem?
Recap
Interview questions
How do you keep data consistent across microservices without distributed transactions?