Workflows, sagas and the outbox
Placing an order means reserving stock, charging a card, booking a courier and sending an email, often in four different services, some owned by other companies. There's no single transaction that spans them all, any step can fail, and the server running the process can crash halfway. This module shows how to make multi-step processes reliable: undo instead of rollback, never lose an event, and let a workflow engine remember where you were.
1. The idea
- A distributed transaction (two-phase commit, 2PC) would lock every participant until all agree. It's slow, a stuck coordinator blocks everyone, and payment providers and other companies' APIs don't support it. Modern systems mostly avoid it across services.
- A saga is a sequence of local steps, each with a compensating action that semantically undoes it (release the stock, refund the card). If a step fails, run the compensations for the completed steps, in reverse order.
- Sagas promise "eventually all done, or all undone", not isolation: for a while an order is visibly "pending", and other requests can see the in-between state. Design the states on purpose.
- The process must survive crashes of the coordinator itself. That needs durable state: which steps completed, with what results.
Two ways to coordinate a saga
| Choreography | Orchestration | |
|---|---|---|
| How | No central controller. Each service reacts to events and emits its own: OrderPlaced → payment charges → PaymentCharged → shipping books → … | One orchestrator (often a workflow engine) tells each service what to do next and records the results |
| Good | Loose coupling; easy to add a new listener; no single component owns the flow | The whole flow is written in one place; easy to see where an order is stuck; easy to change the order of steps |
| Bad | The flow exists only as an emergent behaviour across services; hard to trace and to change | The orchestrator is a dependency every step touches (run it reliably) |
| Use for | Simple flows with a few steps owned by different teams | Complex flows, long waits, human steps, anything you'll need to debug at 3 a.m. |
2. How it works
An order saga that fails at step 3
The dual-write problem and the outbox
Almost every step above does two things: update its own database, and tell the world (publish an event). Doing those as two separate writes is the dual-write problem: if the process crashes between them, the database says "charged" but no event was published, and the saga stalls forever, or the event goes out for a change that was rolled back.
The transactional outbox fixes it. In the same local transaction as the business change, insert the event into an outbox table. A separate relay (a poller, or change data capture such as Debezium reading the database log) publishes outbox rows to the message broker and marks them sent. The change and the event now commit together or not at all; publishing is at-least-once, so consumers stay idempotent (Module 18). Try both in section 3.
BEGIN;
UPDATE payments SET status = 'charged' WHERE order_id = 881;
INSERT INTO outbox (id, topic, payload) VALUES (gen_random_uuid(), 'payments', '{"order":881,"event":"PaymentCharged"}');
COMMIT; -- both rows, or neither
-- relay: SELECT ... FROM outbox WHERE sent = false → publish → mark sent (or Debezium streams the inserts)
Durable execution: workflows as ordinary code
Workflow engines remove most of the hand-built machinery. In Temporal (and similar engines), you write the saga as a normal function. Every step that touches the outside world is an activity whose result the engine records in an event history. If the worker crashes, another worker replays the function from the start; completed activities return their recorded results instead of running again, so execution continues exactly where it stopped. Waiting ("sleep 30 days", "wait for the courier to accept") costs nothing while it waits. Two rules come with it: workflow code must be deterministic (no direct clock reads, randomness or network calls; those go in activities), and activities must be idempotent, because they can be retried.
// a Temporal-style workflow (TypeScript), simplified
async function placeOrder(order) {
const compensations = [];
try {
await reserveStock(order); compensations.push(() => releaseStock(order));
await chargeCard(order); compensations.push(() => refundCard(order));
await bookCourier(order); compensations.push(() => cancelCourier(order));
await sendConfirmation(order);
} catch (err) {
for (const undo of compensations.reverse()) await undo(); // also retried until they succeed
throw err;
}
}
3. Try it yourself
Run the saga
Choose which step fails (if any) and whether a compensation also fails, then run it.
Dual write vs outbox, with a crash
4. Trade-offs
Undo isn't rollback
A refund isn't the same as never charging: the customer saw a pending charge, the email may have gone. Some steps can't be undone at all; put those last ("send confirmation" after everything that can fail).
Compensations fail too
Retry them until they succeed (they must be idempotent), and have a manual fallback: an alert and a queue of stuck orders for a human.
Engine vs hand-built
A workflow engine gives state, retries, timers and visibility for free, at the cost of a new system and its rules (determinism, versioning). For one async step, a queue is enough.
Keep transactions local when you can
If stock and orders live in one database, one ACID transaction beats any saga. Sagas are for boundaries you can't avoid: other services, other companies, long waits.
5. In real systems
- Temporal (open source, or Temporal Cloud) is the most widely used durable-execution engine; it grew out of Uber's Cadence. Similar ideas appear in Restate, DBOS and Azure Durable Functions.
- AWS Step Functions defines workflows as JSON state machines. Standard workflows can run for up to one year; inputs and outputs are limited to 256 KiB, so pass IDs and S3 keys, not documents. Google Cloud Workflows and Azure Logic Apps are similar.
- Apache Airflow and Dagster orchestrate scheduled data pipelines (Module 23), not user-facing transactions.
- Outbox relays: Debezium's outbox event router streams an outbox table to Kafka through change data capture; many frameworks include a polling outbox.
- Versioning running workflows: changing code while thousands of workflows are mid-flight requires care. Engines let old runs keep the old logic (Temporal's patching, Step Functions' versions) while new runs use the new.
The technologies up close
driver accepts,
user signs):
an orchestrated saga on a workflow engine such as Temporal, each step idempotent with a compensation, events published through an outbox.Then name the most important failure:
if booking the courier fails, refund and release the stock.Progression: single server → saga → workflow engine; don't jump to the engine for a single async step.
Why the naive version breaks: one server running the steps in order loses all memory of progress when it crashes (charged, but stock never reserved), and a payment webhook may land on a different server. Patching that with database checkpoints and pub/sub means hand-building a state machine, a poller for stalled orders, locks so two servers don't grab the same order, retries, and still no compensation. That's the signal to use a saga on a workflow engine.
| Deep-dive question | Answer |
|---|---|
| The coordinator crashes mid-way | Progress is stored durably; on restart it resumes forward or compensates; steps are idempotent. Workflow engines give you this. |
| Change a workflow with 10,000 runs in flight | Versioning (old runs keep old code) or a deterministic branch: Temporal's patched(); Step Functions runs stay pinned to their original definition |
| The history grows too big | Pass IDs, not payloads; Continue-as-New snapshots the state and starts a fresh run with an empty history |
| Wait 5 minutes or 5 days for a signature | A signal plus a durable timer: wait up to 30 days, remind, wait 7 more, then cancel. The webhook calls the engine, which wakes the workflow |
| A refund or email must happen exactly once | Delivery is at-least-once, so make the effect exactly-once with an idempotency key: mark IN_PROGRESS → do it → COMPLETED. COMPLETED → skip; IN_PROGRESS → reconcile with the provider rather than blindly retry |
Durable execution (Temporal)
Situational · the preferred tool for complex flowsWrite the workflow as ordinary code; the engine records every step's result, so after any crash the workflow continues exactly where it stopped.
How it works
- Workflow = the flow and its decisions. It must be deterministic: no direct network calls, random numbers or clock reads.
- Activity = one step that touches the outside world (charge, reserve, email). It must be idempotent, because it may be retried.
- Recovery by replay: every activity result is saved in a history database. After a crash, a new worker reruns the workflow from the top; finished activities return their saved results instead of running again. The customer is charged once.
- Signals and timers: wait for external events (a driver accepts, a document is signed) or for days, without holding a thread.
- Parts: the Temporal server (schedules tasks and timers, records progress, never runs your code), its history database, and your worker pools.
Status today
Temporal (open source or Temporal Cloud) is the most widely used engine; it grew out of Uber's Cadence. Restate, DBOS and Azure Durable Functions use similar ideas. The tool matters less than knowing how it recovers and its limits.
Use it when
- Flowcharts with failure branches: "if X fails, undo Y"
- Payments and e-commerce orders, onboarding
- Human-in-the-loop steps and long waits; AI-agent pipelines; audit needs
Skip it when
- A single async step (resize an image, send an email): a queue is enough
- Synchronous, latency-sensitive requests
- Huge volumes of low-value operations (engine overhead per step)
In the interview
Mid-level: recognise the need and name a tool. Senior: defend it: An orchestrated saga on Temporal: activities are idempotent with compensations; if the worker crashes after charging, replay returns the saved charge result instead of charging again.
- Naming Temporal without knowing how it recovers (history and replay) or its rules (deterministic workflows, idempotent activities).
- A workflow engine for one async step.
- Non-deterministic workflow code (calling an API or reading the clock directly), which breaks replay.
Managed state machines (AWS Step Functions)
Situational · managed, simpler flowsWorkflows defined as state machines in JSON or YAML (or generated with AWS CDK), run by the cloud provider.
How it works
Each state calls a service (Lambda, an API, a queue), with retries, timeouts, branches and waits declared in the definition. You get a visual diagram of every run and nothing to operate; it's less expressive than code. Standard workflows run up to one year, with 256 KiB payloads, so pass IDs.
Status today
Mature; Google Cloud Workflows and Azure Logic Apps or Durable Functions are the equivalents.
Use it when
- AWS-based stacks with moderate complexity
- Teams that want visual, managed workflows
Skip it when
- Very complex logic that's easier as code
- Large payloads between steps
In the interview
On AWS, Step Functions orchestrates the saga: each state retries with backoff and has a catch that runs the compensation.
- Passing whole documents between states and hitting payload limits.
Event-driven choreography
Situational · mid-complexity flowsNo coordinator: each service listens for events in a durable log and emits its own.
How it works
OrderPlaced → payment worker → PaymentCharged or PaymentFailed → inventory worker → … Compensation flows the same way (InventoryFailed → refund → PaymentRefunded). Consumer groups commit offsets, so workers must be idempotent; scale is up to the partition count.
Status today
Common on Kafka or Redis Streams, with an outbox in each service.
Use it when
- Independent teams owning each step
- A handful of steps, and many listeners interested in the same events
Skip it when
- Complex flows: the logic exists nowhere in one place, and adding a step means changing event contracts
In the interview
A three-step flow owned by different teams: choreography over Kafka, each service with an outbox; beyond that I'd move to orchestration.
- Choreography for a long, branching flow: nobody can say where an order is stuck.
Transactional outbox
The default · whenever a step publishes an eventWrite the business change and the event to publish in the same local transaction; a relay publishes the event afterwards.
How it works
Covered in section 2: the outbox table commits with the change, and a poller or CDC (Debezium) publishes rows to the broker. Publishing is at-least-once, so consumers stay idempotent.
Status today
Standard practice; supported by many frameworks and by Debezium's outbox router.
Use it when
- Any service that updates its database and must tell others
Skip it when
- Nothing else to notify
In the interview
Each step writes its change and an outbox row in one transaction, so a crash can't leave "charged" without the event.
- Writing to the database and then publishing as two separate steps: the dual-write problem.
Apache Airflow (and Dagster)
Rare here · for data pipelinesSchedulers for batch data pipelines: DAGs of tasks run on a schedule.
How it works
Tasks run in dependency order on a schedule ("every night at 2:00"), with retries and backfills (Module 23).
Status today
The common choice for ETL and analytics pipelines.
Use it when
- Nightly ETL, reports, ML training pipelines
Skip it when
- User-facing transactions and event-driven flows
In the interview
Only for batch: The nightly revenue report is an Airflow DAG; orders use the workflow engine.
- Airflow for an order or payment flow. It's a batch scheduler, not a transaction coordinator.
6. Remember it
How this hook works: what "Write it down before you do it; know how to undo it" means
What the slogan means, phrase by phrase
- "Write it down"
- Durable state: the outbox row committed with the business change, and the workflow's event history of completed steps.
- "before you do it"
- Nothing important lives only in memory. After any crash, the written record says what happened and what's next.
- "know how to undo it"
- Every step has a compensating action, idempotent and retried, so a failure later in the flow can be unwound.
Why this picture fits: it's the travel agent's checklist. With it, an interruption is a pause; without it, it's a mess of half-made bookings nobody remembers.
The story → the concept
| Travel agent | System |
|---|---|
| Flight, hotel, car | Saga steps in different services |
| Cancelling the hotel when the car fails | A compensating action |
| The checklist of what's booked | The workflow's event history |
| "Booked" note and the email to the client, written together | The transactional outbox |
| The agent who runs the whole booking | An orchestrator |
| Each supplier reacting to the previous one | Choreography |
Watch it on an example
- A ride-hailing trip: match driver, wait for acceptance (up to 30 s), charge at the end, pay the driver weekly.
- A workflow per trip records each step; the "wait for acceptance" is a durable timer, so a deploy doesn't lose it.
- The charge fails (card expired): the compensation isn't "un-drive", it's "mark the trip unpaid, notify the rider, retry the card daily".
- Every state change publishes through the outbox, so the receipts and analytics services never miss one.
The trap it saves you from
A chain of HTTP calls in one request handler: it works until the server restarts halfway, and then the customer is charged with no order and nobody knows.
It still works when…
The steps are AI agent tool calls (Module 29): long-running, failure-prone, partly irreversible. The same durable-execution and compensation ideas apply.
7. Common mistakes
8. Design exercise (30 minutes)
- Design a saga for booking a flight + hotel + car through three partner APIs. List each step, its compensation, and which step goes last and why.
- Choose coordination: choreography or orchestration for (a) "user signs up → welcome email + analytics event", (b) a mortgage application with document checks and a human approval. Justify both.
- Add an outbox to the payment service: write the table schema and the relay's loop, and say how consumers handle duplicates.
- Handle a 7-day wait: a contract must be signed within 7 days or the order is cancelled. Describe it with a durable timer and a signal.
- Write it down: saga = steps + compensations; outbox = change and event in one transaction.
9. Self-check quiz
In a saga, step 3 of 4 fails. What happens?
How do you make sure an event is published if and only if the database change commits?
How does a durable workflow engine resume after a worker crash?
A loan process with ten steps, a human approval and a 30-day deadline. Which coordination style?
A Temporal worker crashes right after the charge activity succeeded. What happens when another worker takes over?
Your only async work is resizing an uploaded image. Workflow engine?
10. Go deeper
| Resource | Time | Why |
|---|---|---|
| Saga pattern and transactional outbox (microservices.io) | 30 min | Concise descriptions of both patterns, with diagrams. |
| Sagas (Garcia-Molina and Salem, 1987) | 30 min | The original paper: long-lived transactions broken into compensatable steps. |
| Temporal documentation | 1 h | Workflows, activities, event history, retries and versioning. |
| Multi-step processes (Hello Interview) | 25 min | The interview framing: when to reach for sagas and workflow engines. |
11. Under the hood (optional)
How workflow engines survive crashes and how schedulers keep millions of timers. Not needed for most interviews; open it when you want to know how the products really do it. All the deep dives are listed on the Under the hood page.
Workflow engines and timersDurable execution and replay (animated), Temporal vs Step Functions vs Airflow, hierarchical timing wheels (animated), delay queues and cron in a clusterabout 20 min
Workflow engines promise that a multi-step process (charge, reserve, ship, email) finishes even if machines crash halfway, and schedulers promise that something happens at the right time. Both rest on a few mechanisms: a durable history, deterministic replay, retries with idempotency, and efficient timer structures.
Durable execution: how Temporal survives a crash
- Event history: the server records every step of a workflow (started, activity scheduled, activity completed with its result, timer fired) in an append-only history.
- Replay: when a worker dies, another worker re-runs the workflow function from the start, but instead of calling activities again it feeds them their recorded results from the history, until it catches up to the point of failure and continues live.
- Determinism is the price: workflow code must make the same decisions on replay, so no direct clock reads, random numbers, network calls or unordered map iteration in it; those go in activities or use the SDK's deterministic versions.
- Activities (the side effects) are retried with backoff until they succeed, so they must be idempotent. Durable timers ("sleep 30 days") are just history events: no process waits for them.
| Engine | Model | Good for |
|---|---|---|
| Temporal (and Cadence) | Workflows as ordinary code, made durable by history and replay | Long, complex business processes; sagas |
| AWS Step Functions | JSON state machines; Standard workflows run up to a year with exactly-once steps, Express up to 5 minutes at-least-once | Orchestrating AWS services without running servers |
| Apache Airflow (and Dagster) | A scheduler parses DAG files and hands tasks to executors on a schedule | Batch data pipelines, not per-request business flows |
Timers at scale: timing wheels
Millions of pending timeouts (request deadlines, delayed retries, session expiries) can't each be a sorted-heap entry costing O(log n) to add and remove. A timing wheel is a circular array of buckets, one per tick; adding a timer drops it into the bucket for its expiry, and each tick fires one bucket: O(1) per operation. Hierarchical wheels (seconds, minutes, hours, like a clock's hands) handle long delays with few buckets, moving timers down as their time approaches. Kafka uses one to track requests waiting for replication, Netty for connection timeouts.
- Delay queues in Redis: a sorted set scored by due time; workers poll for
ZRANGEBYSCORE key -inf nowand claim items atomically (a Lua script orZPOPMINwith a check). - Cron in a cluster: elect one scheduler (a lease in etcd or the database) so jobs don't run on every replica; record each run so a restart doesn't skip or double-run it; decide explicitly what happens to missed runs (catch up or skip) and overlapping runs (queue, skip or allow). Design D21 builds a distributed job scheduler.
Say it in the interview
- "What if the order service crashes between charging and reserving stock?" With a durable workflow engine, another worker replays the history, sees the charge already completed, and continues with the reservation; activities are idempotent so a retried charge can't double-bill.
- "How do you schedule a reminder in 30 days for 50 million users?" Not 50 million sleeping threads: store due times in a time-partitioned table or delay queue, and have workers pick up what's due each minute (or a workflow engine's durable timers).
Why must Temporal workflow code be deterministic?
What does adding a timer to a timing wheel cost?