The Saga Pattern, Explained for Carrier Integrations

What the saga pattern is, how compensating transactions work, and a worked carrier-booking example showing exactly what unwinds on failure.

Share

Step 3 of your carrier booking flow just failed. The shipment record exists, the carrier has confirmed the booking, but the label service timed out. Now what? This is exactly the problem the saga pattern was built to solve, and it's worth understanding properly before you wire four HTTP calls together and hope for the best.

What the saga pattern actually is

A saga is a sequence of local transactions, each with its own compensating action, that together keep data consistent across services without a distributed lock or two-phase commit. The saga pattern manages distributed transactions by breaking them into a sequence of local transactions, each followed by a compensating action that undoes the work if a later step fails, eliminating the need for a distributed lock or two-phase commit. That's the whole idea in one sentence. The pattern isn't new. The saga pattern was introduced by Hector Garcia-Molina and Kenneth Salem in 1987, originally as a way to handle long-lived database transactions on a single node. Microservices architects borrowed it decades later because the same problem — a long-running process that touches multiple independently-committing resources — shows up constantly once you split a system into services that each own their own data.

The mechanics are simple to state and harder to implement well. If step 3 of 5 fails, the saga runs compensating transactions for steps 2 and 1 to undo their effects. The end result is either all steps succeed, or all effects are rolled back, achieving consistency without distributed locks. Crucially, this isn't a database ROLLBACK. A saga doesn't roll back database transactions, it performs new transactions whose purpose is to restore the system to a valid business state. Cancelling a carrier booking is a new API call, not an undo button.

Saga vs two-phase commit: why 2PC doesn't work here

Two-phase commit (2PC) is a coordinator-driven protocol where every participant votes before anyone commits. It gives you strong atomicity, but at the cost of blocking. Two-Phase Commit coordinates distributed transactions: Phase 1 (Prepare), coordinator asks participants to vote YES/NO, Phase 2 (Commit), if all vote YES, coordinator sends commit, otherwise abort. Every participant holds its locks open for the full round trip.

That's the fundamental trade-off with a saga. The primary difference between Two-Phase Commit and the Saga Pattern is their approach to data consistency. Two-Phase Commit ensures strong consistency by maintaining atomicity, which means that all participating nodes in a distributed transaction must either commit or roll back the transaction together. In contrast, the Saga Pattern provides eventual consistency rather than strong consistency. It breaks a significant transaction into a series of independent local transactions, where each service commits its own work immediately.

In carrier integration this isn't really a choice. A carrier booking API, a label generation service, and a billing gateway are typically separate vendors you don't operate. 2PC only makes sense when you control every participant, they all support prepare and transactions are short — none of which applies when you're calling DHL's booking endpoint, your own label renderer, and a payment processor in sequence. None of these third-party carrier APIs expose a "prepare" phase to vote on. There is no realistic way to ask DHL's booking system to hold a lock open while you wait for your billing provider to confirm. The saga pattern isn't the elegant option here, it's the only option.

Saga vs "just retry" and vs the outbox pattern

People conflate sagas with other reliability patterns because they all deal with failure, but they solve different layers of the problem.

PatternWhat it doesScope
Transactional outboxGuarantees a single event is published reliably alongside a local database commitOne service, one write
Retry with backoffHandles a transient failure of one call (timeout, 503, network blip)One step, one call
SagaDecides and executes the unwind across multiple services when a step fails permanentlyThe whole multi-step business process

Idempotency keys and retries make each individual step reliable. The outbox pattern makes sure a service doesn't lose an event between its database write and its message publish. The saga sits above all of that: it decides what happens across services once a step has exhausted its retries and genuinely failed. You still need retries and idempotency inside each saga step — a saga that calls a non-idempotent "cancel booking" endpoint twice because of a retry is its own incident waiting to happen.

Orchestration vs choreography

There are two ways to coordinate the steps of a saga, and the choice shapes how debuggable your system is at 3am.

  • Choreography: each service listens for the previous service's event and reacts on its own. The events/choreography approach establishes that all microservices involved work triggered by specific events, like proper choreography, through the exchange of messages among microservices, usually with a Message Broker. No single point of failure, but tracing a failure means hopping between five services' logs.
  • Orchestration: a central coordinator issues commands to each participant and explicitly triggers compensations on failure. In orchestration, the orchestrator triggers compensations automatically, while a central service manages the transaction's flow, ensuring steps execute in the correct order and triggering compensation actions when needed. You get one place to look for the state of any given booking, at the cost of running another component.

Workflow engines like Temporal are one concrete way to implement orchestration, not a different pattern. Maersk is a documented example of this in logistics: the Maersk team jokes that Temporal is their time machine, estimating it now takes 5 to 10 days to add new features instead of the previous 60 to 80 days, partly by letting the orchestrator own retry and compensation logic that engineers previously hand-rolled per service. For most carrier middleware, orchestration wins on observability grounds alone. When a shipper's finance team asks "why is booking #48213 stuck", you want one saga state machine to query, not five Kafka topics to correlate.

Worked example: a four-step carrier booking saga

Take a typical outbound shipment flow with four steps, each owned by a different service or third party:

StepForward actionCompensating action
1. Create shipmentWrite shipment record, status = PENDINGCancel shipment record, status = CANCELLED
2. Book carrierCall carrier API, reserve capacity, get tracking numberCall carrier's cancel-booking endpoint, release tracking number
3. Generate labelRender and store PDF/ZPL labelVoid the label, remove it from storage
4. Charge shipperDebit shipper's prepaid balance or cardRefund or reverse the charge

Now walk through the failure: step 3, label generation, fails for good after retries are exhausted (the label renderer is down, or the carrier's label template has changed and every attempt returns a schema error). At this point steps 1 and 2 have already committed locally. The carrier has a confirmed booking. The shipper's finance system has nothing debited yet, because step 4 never ran. The saga's job is to unwind steps 2 and 1, in reverse order, because the forward work never reached the pivot point. If any step fails, you execute compensation transactions in reverse order to undo the effects of all completed steps. So:

  1. Compensate step 2: call the carrier's cancellation endpoint to release the booking and the tracking number.
  2. Compensate step 1: mark the shipment record CANCELLED rather than deleting it, so you have an audit trail of what was attempted.

Step 4 never ran, so there's nothing to compensate there — no charge, no refund needed. This is the point of designing the saga around real failure boundaries: a half-booked shipment that silently sits in your database as "PENDING" forever is a support ticket and a reconciliation headache. A shipment correctly marked CANCELLED with a visible compensation trail is just data.

Middleware vendors handling multi-step carrier orchestration differ in where they put this compensation logic. Platforms like Cargoson, nShift, MercuryGate, and Shippo-style parcel APIs each take a different stance on whether compensation lives in the orchestrator, in per-carrier adapter code, or is left to the integrator to build. None of that changes the underlying mechanics: something has to own the reverse-order unwind, explicitly, with its own retries.

Where sagas break down in carrier integration

Not every step can be undone. Once a courier has physically collected a parcel, you cannot "un-pickup" it — you can only cancel the onward leg, charge a collection fee, or ask the carrier to intercept it. This is the pivot transaction concept: the point past which compensation stops being a soft rollback and starts being damage control. Design your saga so the physically irreversible step (dispatch, pickup) sits as late as possible, with everything reversible happening before it. The other known trade-off is semantic isolation. No isolation: Intermediate states are visible to other transactions. While your saga is mid-flight, a dashboard or a webhook consumer downstream may see a shipment as "booked" a few seconds before a later step fails and the saga reverses it. If your webhook delivery fires on step 2's success rather than on final saga completion, you've just told a shipper's ERP something that may become false moments later. Fire customer-facing events only once the saga reaches a terminal state, not on each intermediate step.

FAQ

Is a saga the same as a workflow engine?
No. Temporal, Camunda, and similar tools are implementation choices for orchestration-style sagas. The pattern itself is coordination logic; the engine just gives you durable execution, retries, and state persistence for free.

Does a saga need a message broker?
Choreography typically relies on one, since services react to each other's published events. Orchestration can run on plain HTTP commands from a central coordinator with no broker at all.

What happens if a compensating action itself fails?
It needs its own retry policy and, eventually, a dead-letter queue with human escalation. A failed compensation (carrier won't accept the cancellation, say) is now an incident, not a background retry.

Is a saga overkill for a two-step flow?
Usually, yes. Plain idempotent retries are enough until you have three or more services with genuine side effects that need undoing in a specific order. Below that, you're adding coordination overhead for a problem that a well-designed retry already solves.