Patterns

Multi-step Processes

Keeping a workflow correct when it spans several writes and external services — transactions where you can, sagas and the outbox pattern where you can't, and designing for retries.


Plenty of operations aren't a single write. Placing an order charges a card, decrements stock, creates a shipment, and sends an email. If the process dies halfway — the server crashes after charging but before shipping — you're left with a corrupt, half-finished state. This pattern is about making multi-step work either complete fully or unwind cleanly.

First choice: can it be one transaction?

If every step touches one database, wrap them in a transaction and you get all-or-nothing for free:

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = A;
UPDATE accounts SET balance = balance + 100 WHERE id = B;
COMMIT;   -- both, or neither

This is the happy case. Prefer it whenever the whole operation lives in one database — don't invent a distributed workflow for something a transaction handles.

When steps span services: the saga

Transactions don't span a payment provider, an email service, and three microservices. For that you use a saga: break the process into steps, each with a compensating action that undoes it. Run them in order; if a step fails, run the compensations for the steps already done, in reverse.

charge card      → compensate: refund
reserve stock    → compensate: release
create shipment  → compensate: cancel shipment

A saga doesn't give you a clean rollback — it gives you a forward-and-backward recovery. You accept that there's a brief window where the world is partly changed, and you design the compensations so it always converges to "fully done" or "fully undone."

Make every step idempotent

Multi-step processes get retried — by queues, by clients, by your own recovery logic. So each step must be safe to run more than once: charging the same card twice is a disaster, sending the same email twice is just annoying. The tools:

  • Idempotency keys with the payment provider, so a retried charge returns the original result.
  • "Done" markers — record that a step completed (order.shipment_created = true) and skip it on replay.
  • Conditional writes — UPDATE ... WHERE status = 'pending' so a second run is a no-op.

The dual-write problem and the outbox

A subtle trap: you write to your database and need to send a message (enqueue a job, publish an event). If the DB write succeeds but the enqueue fails (a broker blip), the step is silently lost. You can't transactionally span a database and a message broker.

The fix is the transactional outbox: in the same DB transaction as your business write, insert a row into an outbox table. A separate process reads the outbox and publishes the messages, marking them sent. Now the message is as durable as the data — they commit together.

one transaction:  [ write order ]  +  [ insert into outbox ]
later:            poller reads outbox → publishes → marks sent

Model the state explicitly

Give the process a status field and a clear set of transitions (pending → paid → shipped → delivered, plus failed/refunded). A crash leaves the row in a known state, and a recovery job can pick up anything stuck. "Where did it stop?" should always have an answer in the data.

What to say in an interview

Reach for a transaction first; only escalate to a saga + outbox when steps cross service boundaries:

"Placing the order touches one database, so stock and order creation go in a single transaction. The payment and the confirmation email are external, so I'd model it as a saga: charge (compensate with refund), then enqueue the email via an outbox so the job can't be lost if the broker hiccups. Every step is idempotent and keyed, because the queue will retry."


Next up: Scaling Reads — serving far more reads than one database can handle.