What is a saga, and why is compensation not a rollback?

A saga is a sequence of local transactions, each committed in its own service's database. If a later step fails, the saga runs compensating transactions that semantically undo the earlier steps. Two-phase commit across services would require every participant to hold locks while a coordinator decides. That couples their availability, and many brokers and managed databases do not support it. Sagas avoid this by committing each step immediately, which means other services see intermediate states.

Committing early is why compensation is not a rollback. A rollback erases uncommitted work. A compensation is new business logic that counteracts effects other systems have already seen. A reservation is released, a payment is voided or refunded, and an email is followed by a correction. Some actions cannot be undone at all. Compensations can also have their own side effects, such as refund fees or a customer seeing "cancelled" on an order they just placed.

The running example reserves inventory, then authorizes payment, then creates a shipment:

Step Type Compensation
Reserve inventory Compensatable Release reservation
Authorize payment Compensatable Void authorization
Create shipment Pivot None; once a carrier commits, the saga must complete
Capture payment, notify customer Retriable None; retry until success

Order the steps so that cheap, invisible-to-customer steps come first, followed by the pivot, followed by steps that are guaranteed to succeed on retry. With this ordering, a failure before the pivot only triggers inexpensive compensations, and a failure after it only triggers retries. This holds in both orchestration and choreography.

Who should use sagas?

Sagas are for teams whose business operations span more than one service, where each service owns its own database and there is no shared transaction manager. Typical cases are order placement, account onboarding, booking flows that combine several resources, and any process that calls an external system you cannot enlist in a transaction, such as a payment provider or a carrier API.

The pattern fits when three conditions hold:

  • The operation crosses ownership boundaries. Different services, and often different teams, own the data each step touches.
  • Temporary inconsistency is acceptable to the business. A customer can briefly see "pending," or stock can be held for a few seconds before it is released.
  • Every step before the pivot has a meaningful undo. If you cannot describe how to compensate a step in business terms, the saga cannot recover from failures after it.

Sagas are a poor fit for a single service with a single database, where a local transaction is simpler and stronger. They also fit poorly where an invariant must never be violated even briefly, a case covered later in this article. Within the teams that do need sagas, choreography suits small groups of services that already communicate through events. Orchestration suits teams that own a long or business-critical flow end to end and need to answer "what state is this order in?" directly.

How does a saga work at runtime?

At runtime, a saga is a loop of four actions, whichever coordination style you choose:

  1. Execute a step as a local transaction. The participant changes its own data and, in the same transaction, records an outcome message through an outbox.
  2. Publish the outcome. A relay delivers the success or failure event, or the reply to the orchestrator, with the saga's correlation ID attached.
  3. Decide the next action. On success, the next participant is triggered. In choreography it reacts to the event. In orchestration the coordinator sends the next command. On failure before the pivot, the saga switches direction.
  4. Compensate in reverse. Each already-completed compensatable step receives its undo action, newest first. Each undo is itself a local transaction that emits its own outcome.

After the pivot succeeds, the direction is fixed. Remaining steps are retried until they succeed and are never compensated. Throughout, every message may be delivered more than once, so each step and each compensation must be idempotent. The two coordination styles differ only in where step 3 is decided, as the next sections show.

How do saga orchestration and choreography work for the same order flow?

Take an order flow with three steps: the Order service creates a pending order, Inventory reserves stock, and Payment authorizes the card. The pivot is payment authorization. Once it succeeds, shipping is only retried and never compensated.

Choreography

Happy path: Order emits OrderCreated. Inventory consumes it, reserves stock, and emits StockReserved. Payment consumes that, authorizes the card, and emits PaymentAuthorized. Order marks itself confirmed.

Failure path: Payment emits PaymentFailed. Inventory has to subscribe to that event and release the reservation. Order also subscribes to it and cancels the order. No single component describes the rollback. Each handler knows only its own part, so to understand the compensation sequence you have to read every subscriber.

Orchestration

Happy path: An orchestrator sends ReserveStock to Inventory and waits for the reply. It then sends AuthorizePayment and waits again. When both succeed, it marks the order confirmed. The participants never learn about each other.

Failure path: Payment replies with a failure. The orchestrator checks its record of completed steps, sees that stock was reserved, sends ReleaseStock, and then cancels the order. The compensation path sits in one place and can be read from top to bottom.

Both versions use messages, and both can run on the same broker. The transport does not separate them. The real choice between saga orchestration and choreography is where compensation logic and failure state live: spread across event handlers, or held by one owner that knows what has already happened.

CHOREOGRAPHY: each handler owns its slice of the rollback

 +-------+  OrderCreated   +-----------+  StockReserved  +---------+
 | Order |---------------->| Inventory |---------------->| Payment |
 +-------+                 +-----------+                 +---------+
   ^   ^                         ^                          |   |
   |   +--- PaymentAuthorized ---|--------------------------+   |
   |    (confirm order)          |                              |
   +----- PaymentFailed ---------+------ PaymentFailed ---------+
   (cancel order)          (release stock)

ORCHESTRATION: one owner holds step state and compensations

    +-------------------------------------------------------+
    |  Orchestrator (owns saga state and step log)          |
    |  happy: ReserveStock -> AuthorizePayment -> confirm   |
    |  fail:  payment failed -> log: stock reserved         |
    |         -> ReleaseStock -> CancelOrder                |
    +-------------------------------------------------------+
        |   ^                  |   ^                    |
        |   | ReserveStock     |   | AuthorizePayment   | ConfirmOrder
        |   | ReleaseStock     |   | reply: ok/fail     | CancelOrder
        v   | reply            v   |                    v
    +-----------+           +---------+             +-------+
    | Inventory |           | Payment |             | Order |
    +-----------+           +---------+             +-------+

Same messages, same broker: the difference is who owns compensation.

The same order saga shown both ways: in choreography, the PaymentFailed rollback is spread across the Inventory and Order subscribers, while in orchestration one coordinator reads its step log and issues ReleaseStock and then CancelOrder.

How does choreography track saga state and compensations without an owner?

In choreography, no component holds the saga, so state is reconstructed from events. Every event carries a correlation ID, usually the order ID or a dedicated saga ID, and every participant copies it onto whatever it emits. Event names state facts in past tense, such as PaymentAuthorizationFailed, InventoryReleased and ShipmentCancelled. This lets subscribers react to what happened without knowing who caused it.

Each service decides what to compensate from its own local records. When the inventory service consumes PaymentAuthorizationFailed, it looks up any reservation stored under that correlation ID, releases it in a local transaction, and emits InventoryReleased with the same ID. If no reservation exists, it still emits the event or records a no-op, because downstream listeners may be waiting for it.

That locality is also the main cost. The end-to-end flow exists only implicitly, spread across subscription lists in different repositories. To answer "where is order 123 stuck?", teams end up building a saga-state projection: a service that subscribes to every saga event and folds them into a per-correlation-ID status. That service is effectively a passive orchestrator.

Other costs appear as the saga grows:

  • Cyclic dependencies. Two services can end up subscribing to each other's events, sometimes indirectly through a third service.
  • Ambiguous ownership. "Who compensates what" bugs grow with the number of participants. A newly added service may never receive the failure event it needs.
  • Harder debugging. Reconstructing one failed order requires distributed tracing with the correlation ID propagated through every broker hop.
// Inventory service: compensates its own reservation when payment fails.
// Reacts to a past-tense fact. It does not know or care who emitted it.
type SagaEvent = { type: string; correlationId: string; occurredAt: string };

interface Tx {
  findReservation(correlationId: string): Promise<{ sku: string; qty: number; status: string } | null>;
  releaseStock(sku: string, qty: number): Promise<void>;
  markReleased(correlationId: string): Promise<void>;
  enqueueOutbox(event: SagaEvent): Promise<void>; // written in the same local transaction
}
interface Db { transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>; }

export async function onPaymentAuthorizationFailed(evt: SagaEvent, db: Db): Promise<void> {
  await db.transaction(async (tx) => {
    // Decide what to compensate from local records only, keyed by correlation ID.
    const reservation = await tx.findReservation(evt.correlationId);

    // Redelivered event: compensation already done, so do not emit twice.
    if (reservation?.status === "RELEASED") return;

    if (reservation) {
      await tx.releaseStock(reservation.sku, reservation.qty);
      await tx.markReleased(evt.correlationId);
    }
    // No reservation is a no-op, but still emit: downstream listeners and the
    // saga-state projection may be waiting for this fact to close the saga.
    await tx.enqueueOutbox({
      type: "InventoryReleased",
      correlationId: evt.correlationId, // copied unchanged so tracing and projections can join
      occurredAt: new Date().toISOString(),
    });
  });
}

A choreography participant consumes PaymentAuthorizationFailed, releases only its own reservation under the same correlation ID, and emits InventoryReleased through a transactional outbox. It skips duplicates and still emits the event when there is nothing to release.

How can you implement saga orchestration with a state machine or Temporal workflows?

A hand-rolled orchestrator is a service that owns a table of saga instances. Each row records the current state, completed steps, and correlation ID. Every transition must update that row and enqueue the next command atomically, which means a transactional outbox. Around that core you build timers for steps that never reply, retry policies with backoff, and a recovery loop that resumes sagas stranded by a crash between "command sent" and "state saved." None of this is exotic, but all of it is infrastructure you now maintain.

A durable workflow engine such as Temporal moves that infrastructure out of your code. The engine records every activity result, timer, and signal in an execution history. After a worker crash, it rebuilds the workflow by replaying deterministic code against that history. Because recovery is automatic, compensation becomes ordinary control flow. The workflow appends an undo action to a list after each step succeeds. On failure, it runs that list in reverse order. Activity retries come from configuration rather than hand-written loops.

The costs:

  • Determinism. Workflow code cannot read clocks, generate random values, or perform I/O directly, because replay must produce identical decisions.
  • Versioning. Changing logic while executions are in flight requires explicit version branches. Otherwise replay diverges from recorded history.
  • Operations. You either run the server and its database or pay for a hosted service.
  • Learning curve. Engineers must internalize replay semantics before they can debug effectively.
  • Coupling. The orchestrator becomes a critical dependency, and business rules tend to migrate into the workflow and away from the services that own them.
// Conceptual workflow: no specific SDK APIs. `acts` stands in for activity stubs
// that the engine schedules, records in history, and retries per configured policy.
interface Acts {
  reserveInventory(orderId: string): Promise<string>;   // returns reservationId
  releaseInventory(reservationId: string): Promise<void>;
  chargePayment(orderId: string, cents: number): Promise<string>; // returns chargeId
  refundPayment(chargeId: string): Promise<void>;
  scheduleShipment(orderId: string): Promise<void>;
}

export async function placeOrder(acts: Acts, orderId: string, cents: number) {
  // Undo actions, registered only after the matching step succeeds.
  const compensations: Array<() => Promise<void>> = [];
  try {
    const reservationId = await acts.reserveInventory(orderId);
    compensations.push(() => acts.releaseInventory(reservationId));

    const chargeId = await acts.chargePayment(orderId, cents);
    compensations.push(() => acts.refundPayment(chargeId));

    // Last step: if it fails after retries are exhausted, the steps above are undone.
    await acts.scheduleShipment(orderId);
  } catch (err) {
    // Unwind in reverse order. Each compensation is itself a retried activity,
    // so it must be idempotent. Keep going if one fails, so the others still run.
    for (const undo of compensations.reverse()) {
      try {
        await undo();
      } catch {
        // Real code would record this for manual follow-up rather than swallow it.
      }
    }
    throw err; // Surface the original failure to the workflow's caller.
  }
  // No Date.now(), Math.random() or direct I/O here. Replay must make the same decisions.
}

A conceptual durable-workflow saga that pushes an undo action after each successful step and runs those actions in reverse order when a later step fails. The engine handles retries and crash recovery.

Why are idempotency and the transactional outbox mandatory in both patterns?

Brokers and workflow engines deliver at least once, so every saga participant will eventually see the same command or event twice. A retry after a lost acknowledgement, a consumer crash before commit, or a redelivered message all produce duplicates. Both forward actions and compensations must therefore be idempotent. Refunding a payment twice is as damaging as charging it twice.

The usual mechanism is an idempotency key, typically the saga ID plus the step name, recorded in a deduplication table with a unique constraint. The handler inserts the key first, for example INSERT INTO processed_messages (key) VALUES (...) ON CONFLICT DO NOTHING in PostgreSQL. If no row was inserted, the work has already been done and the handler skips it.

Compensations also need to tolerate arriving early or alone. A "cancel before try" happens when the compensation for a reservation arrives before the reservation itself. The participant should record a tombstone for that key, so a late forward action is rejected instead of applied. If nothing exists to undo, the compensation should succeed as a no-op.

Timeouts make this harder because they are ambiguous. A timed-out call may have succeeded, so a compensation can race a late success. Before compensating, query the participant for the step's actual status, or rely on the tombstone to fence out the late write.

Finally, a broker usually cannot join the service's database transaction. The transactional outbox solves this. Write the dedup row, the business change, and an INSERT INTO outbox row in one local transaction, and let a relay publish the outbox afterward.

-- PostgreSQL. Assumed tables:
--   processed_messages(key text PRIMARY KEY, processed_at timestamptz DEFAULT now())
--   outbox(id bigserial PRIMARY KEY, saga_id text, event_type text,
--          payload jsonb, created_at timestamptz DEFAULT now())
BEGIN;

WITH dedup AS (
  -- Idempotency key = saga ID + step name. A redelivered command inserts
  -- nothing, so every later CTE produces zero rows and the step is skipped.
  INSERT INTO processed_messages (key)
  VALUES ('saga-7f3a91:reserve-stock')
  ON CONFLICT (key) DO NOTHING
  RETURNING key
),
reserved AS (
  -- Business change runs only on the first delivery of this key.
  UPDATE inventory
  SET available = available - 2
  WHERE sku = 'SKU-1042'
    AND available >= 2
    AND EXISTS (SELECT 1 FROM dedup)
  RETURNING sku
)
-- Exactly one outcome event per first delivery, written in the same local
-- transaction. A separate relay reads the outbox and publishes to the broker.
INSERT INTO outbox (saga_id, event_type, payload)
SELECT
  'saga-7f3a91',
  CASE WHEN EXISTS (SELECT 1 FROM reserved)
       THEN 'StockReserved' ELSE 'StockReservationFailed' END,
  jsonb_build_object('sku', 'SKU-1042', 'quantity', 2)
FROM dedup;

-- Dedup row, stock change and outbox event commit or roll back together.
COMMIT;

A PostgreSQL saga step that records its idempotency key, applies the business change only on first delivery, and writes the outcome event to the outbox. All three happen in one local transaction.

How do you handle isolation anomalies and failed compensations?

A saga commits each local step as it goes, so it provides atomicity through compensation but no isolation. Between "inventory reserved" and "payment captured," other requests can read and act on that intermediate state. A second order might see stock that is about to be released, or a refund might race a charge that has not settled yet.

Countermeasures

  • Semantic locks: Mark records as PENDING so other flows can see they are in flight and either wait, reject the request, or treat the data as provisional.
  • Commutative updates: Design operations so their order does not matter. For example, debit and credit a balance instead of overwriting it.
  • Re-read before acting: Check current values at the step that commits, and don't trust what an earlier step observed.
  • Step ordering: Put retriable steps that cannot fail for business reasons last, and put the riskiest steps first. This shortens the window in which compensatable state is visible.

Each countermeasure adds states, transitions, and checks that every consumer must understand. If you need many of them for one flow, the data may belong in a single service with a real transaction. In that case, redrawing the service boundary is often cheaper.

When compensation itself fails

Retry with exponential backoff and jitter, because most failures are transient. After a bounded number of attempts, move the saga to an explicit COMPENSATION_FAILED state, raise an alert, and place it on a manual-intervention queue. A log line is not enough. Someone must be able to see the stuck saga, know what was half-done, and follow a runbook that lists the exact corrective actions for that step.

When should you choose orchestration, choreography, or neither?

Choose choreography when the flow is short, has three or four participants, and no single team owns the end-to-end outcome. Each service reacts to events it already cares about, and you avoid running a coordinator. The cost appears as the flow grows. No one place describes the sequence, so adding a step or reordering compensations means changing several services and redeploying them in lockstep. Debugging a stuck order then means correlating logs across all of them.

Choose orchestration when the flow is long-running, has timeouts or human approvals, or needs compensations in a strict order. A hand-rolled orchestrator ends up reimplementing durable timers, retries, and crash recovery. Its bugs appear only when a worker dies mid-step, which is the case you test least. A durable workflow engine such as Temporal gives you those primitives, so your team writes business logic instead of recovery code.

Avoid sagas when the domain needs strong consistency, such as a balance that must never go negative. Redraw service boundaries so the invariant lives inside one local ACID transaction. Two-phase commit is an option only where the participants actually support it, for example several resources managed by one transaction coordinator. A common hybrid is to orchestrate inside a bounded context and publish events between contexts.

Concern Choreography Orchestration
Compensation location Spread across consumers Central workflow definition
Failure-state visibility Reconstructed from events Queryable per saga
Coupling Event schemas, implicit order Orchestrator knows every participant
Observability Needs distributed tracing Workflow history plus tracing
Testing End-to-end, hard to isolate Workflow unit tests with mocked steps
Operational cost No coordinator, higher debugging effort Engine or orchestrator to run

What are the alternatives to sagas?

Sagas are one of several ways to keep data consistent across boundaries. Consider the alternatives before committing to compensation logic:

  • A single local ACID transaction. Move the data that shares an invariant into one service and one database. This is the strongest and simplest option, and it is often the right answer when a saga would need many isolation countermeasures.
  • Two-phase commit (2PC / XA). A coordinator asks every participant to prepare, then commit. It provides atomicity and isolation, but participants hold locks until the coordinator decides, and a coordinator failure can leave them blocked. It works only where every resource supports the protocol, which rules out most external APIs and many brokers.
  • Try-Confirm-Cancel (TCC). Each participant exposes a tentative "try" that reserves resources without making them final, followed by "confirm" or "cancel." This is close to a saga built entirely from reservations. It narrows the isolation gap because nothing is final until confirmation, but every participant must implement all three operations.
  • Eventual consistency with reconciliation. Some flows do not need an automated undo. Each service publishes events through an outbox, and a periodic reconciliation job finds and repairs mismatches, sometimes with a human in the loop. This fits low-volume or low-risk flows where an occasional manual fix is cheaper than building and testing compensations.
  • Avoiding the distributed write. Sometimes one service can call another synchronously, read what it needs, and perform the only write itself. Alternatively, the second effect can be made a pure retriable step that never needs undoing. If no step before the pivot is compensatable, there is nothing to compensate.

Choose a saga when the operation must span independently owned data stores, at least one participant cannot join a distributed transaction, and the business can describe a meaningful undo for each early step.

How can the right saga pattern save engineering time?

Most engineering time in saga-based systems goes into three activities: diagnosing stuck or half-compensated instances, changing the flow, and rebuilding reliability plumbing. Each design decision in this article targets one of them.

  • Diagnosis. An orchestrator or a saga-state projection answers "where is order 123 stuck?" with a single query. Without one, the question requires searching logs across every participant. An explicit COMPENSATION_FAILED state with a runbook turns an open-ended investigation into a checklist.
  • Change. In orchestration, adding or reordering a step usually means editing one workflow definition and the affected participant. In a choreographed flow, the same change can touch every subscriber whose events shift.
  • Plumbing. A durable workflow engine supplies timers, retries, and crash recovery that a hand-rolled orchestrator would otherwise need to build, test, and maintain. A shared library for idempotency keys and the outbox prevents each service team from reimplementing them slightly differently.
  • Avoided work. Correct step ordering, with the pivot placed after cheap compensatable steps, reduces the number of compensations you need to write and test at all. Recognizing early that a flow belongs in one local transaction avoids building a saga altogether.

An illustrative estimate, using assumed inputs only: suppose a team handles 6 stuck-order investigations a month. In a choreographed flow without a state projection, each is assumed to take 3 engineer-hours of cross-service log correlation. With a queryable saga state and a runbook, each is assumed to take 1 hour. Under those assumptions, the saving is 12 engineer-hours a month. This must be weighed against the assumed one-off cost of building the projection or adopting an orchestrator, plus its ongoing operational cost. Replace these assumed figures with your own incident counts and timings before using the estimate in a decision.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to saga orchestration vs choreography, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.