
The Saga Pattern in Distributed Systems: Managing Data Consistency Across Microservices
Table of Contents15 sections
When a customer places an order on an e-commerce site, a lot happens behind the scenes: an order record gets created, payment gets charged, inventory gets reserved, and a shipment gets scheduled. In a monolithic application, all of that can live inside a single ACID transaction: if anything goes wrong, the database rolls everything back and it's as if nothing happened.
Microservices don't get that luxury. Each of those steps typically lives in a different service, backed by a different database. So what happens when the payment succeeds but the warehouse turns out to be out of stock? This is exactly the problem the Saga Pattern was designed to solve.
TL;DR: A saga breaks a distributed transaction into a series of local transactions, one per service, each paired with a compensating transaction that can undo its effect. If any step fails, the saga runs the compensations for everything that already succeeded, trading strict atomicity for a system that stays loosely coupled, available, and eventually consistent.
The Problem: Distributed Transactions in a Microservices World
"Database per service" is one of the core tenets of microservices architecture: each service owns its data, and no other service reaches into that database directly. That's great for independent scaling and deployment, but it means a single business operation (like "place an order") can no longer be wrapped in one local transaction, because the data it touches is scattered across multiple, independently owned databases.
The traditional database answer to this kind of problem is the Two-Phase Commit (2PC) protocol: a coordinator asks every participant to "prepare" the transaction, waits for everyone to agree, and only then tells them all to commit. It works, but it comes with real costs in a microservices context:
It's blocking: every participant holds locks on its data for the full duration of the transaction, including while waiting on the network.
The coordinator is a single point of failure; if it crashes mid-protocol, participants can be left holding locks indefinitely.
Many of the datastores and message brokers common in microservices (NoSQL databases, Kafka, and similar) simply don't support the XA/2PC protocol.
It works against the main reason most teams adopt microservices in the first place: independent scalability and availability.
The Saga Pattern offers a different trade-off: give up strict atomicity and immediate consistency in exchange for a system that stays loosely coupled and available, with an explicit mechanism for undoing partial work when something fails.

What Is the Saga Pattern?
A saga is a sequence of local transactions. Each local transaction updates the database of a single service and then publishes an event or message that triggers the next local transaction in the sequence. If any step fails, the saga runs a series of compensating transactions: new transactions that semantically undo the effect of the steps that already succeeded, rather than relying on a database-level rollback.
There are two common ways to coordinate the steps of a saga.
Choreography-Based Saga
In a choreography-based saga, there's no central coordinator. Each service subscribes to the events it cares about, performs its own local transaction, and publishes a new event describing what happened. The next service in line reacts to that event the same way.
This keeps every service simple and fully decoupled from the others: a service only needs to know about the events it produces and consumes, not about the rest of the workflow. It works well for sagas with a small number of steps. As the number of participating services grows, though, the overall business process becomes implicit, scattered across everyone's event handlers, and harder to see as a whole.
Orchestration-Based Saga
In an orchestration-based saga, a central saga orchestrator (sometimes called a Saga Execution Coordinator) owns the definition of the workflow. It explicitly sends commands to each participant telling it what local transaction to run, waits for a reply, and decides what happens next: move on to the next step, or start compensating the steps that already succeeded.
This centralizes the business logic in one place, which makes the overall process much easier to understand, monitor, and modify, especially once a saga has more than a handful of steps or has conditional branches. The trade-off is that the orchestrator itself becomes a piece of critical infrastructure: it needs to persist its own state so it can resume correctly after a crash, which is why teams often build orchestration-based sagas on top of a workflow engine (Temporal, Camunda, and AWS Step Functions are common choices) rather than as bespoke code.

When Should You Use the Saga Pattern? (Usage Context)
The Saga Pattern is a good fit when:
A single business process spans multiple services, each with its own database.
The business can tolerate eventual consistency (a short window where the system is mid-flight) rather than requiring immediate, all-or-nothing consistency.
You want to avoid the tight coupling and availability cost that distributed transactions impose across services.
The process is long-running, anywhere from seconds to days: order fulfillment, travel booking (flight + hotel + car rental), loan approval workflows, or a ride-hailing trip lifecycle are all typical examples.
It's usually not the right tool when:
The business genuinely requires strict, immediate atomicity across all the data involved. Some regulated financial operations fall into this bucket, and it's worth looking at 2PC, TCC, or redesigning service boundaries so the operation stays inside one service.
The whole operation actually lives inside a single service and database: a local ACID transaction is simpler and sufficient.
Your team doesn't yet have the operational tooling (distributed tracing, idempotent messaging, monitoring) that sagas need in order to stay debuggable in production.
Case Study: E-Commerce Order Processing Saga
Let's walk through a concrete example using an orchestration-based saga with four participating services: Order, Inventory, Payment, and Shipping.
Happy path:
The Order Service creates a new order with status
PENDING.The Inventory Service reserves the ordered items.
The Payment Service charges the customer.
The Shipping Service schedules the delivery.
The Order Service marks the order
CONFIRMED.
Failure path: suppose the customer's card is declined at step 3. The orchestrator now has to undo everything that already succeeded, in reverse order:
Compensate the Inventory Service: release the reserved items back into stock.
Compensate the Order Service: mark the order
CANCELLED.
Notice that "compensating" the order creation doesn't mean deleting the row; it means recording a new, business-meaningful state (CANCELLED) so the history stays auditable. That's true of saga compensations in general: they're forward-moving transactions that semantically reverse an effect, not a literal database-level undo.

Pseudo-Code Demonstration
The pseudo-code below is intentionally language-agnostic: treat things like
db,paymentGateway, andshippingServiceas stand-ins for whatever clients your own stack uses.
Orchestration-Based Saga
class OrderSagaOrchestrator:
steps = [
Step(name: "createOrder", action: createOrder, compensation: cancelOrder),
Step(name: "reserveInventory", action: reserveInventory, compensation: releaseInventory),
Step(name: "processPayment", action: processPayment, compensation: refundPayment),
Step(name: "arrangeShipping", action: arrangeShipping, compensation: cancelShipping)
]
function execute(orderRequest):
completed = []
for step in steps:
result = step.action(orderRequest)
if result.failed:
log("Saga failed at step: " + step.name + ", reason: " + result.reason)
compensate(completed, orderRequest)
return SagaResult.FAILED
completed.push(step)
return SagaResult.SUCCESS
function compensate(completedSteps, orderRequest):
for step in reverse(completedSteps):
try:
step.compensation(orderRequest)
catch compensationError:
// Compensation failures need a human or a retry queue.
// Silently swallowing them can leave the system inconsistent.
alertOpsTeam(step, compensationError)
// Local transaction handlers: each one lives inside its own service
function createOrder(request):
order = db.orders.insert(status: "PENDING", items: request.items, customerId: request.customerId)
return Result.success(order)
function cancelOrder(request):
db.orders.updateStatus(request.orderId, status: "CANCELLED")
function reserveInventory(request):
if inventoryDb.stock[request.itemId] < request.quantity:
return Result.failure("OUT_OF_STOCK")
inventoryDb.stock[request.itemId] -= request.quantity
return Result.success()
function releaseInventory(request):
inventoryDb.stock[request.itemId] += request.quantity
function processPayment(request):
charge = paymentGateway.charge(request.customerId, request.amount)
if charge.declined:
return Result.failure("PAYMENT_DECLINED")
return Result.success(charge)
function refundPayment(request):
paymentGateway.refund(request.chargeId)
function arrangeShipping(request):
shipment = shippingService.schedule(request.orderId, request.address)
return Result.success(shipment)
function cancelShipping(request):
shippingService.cancel(request.orderId)Choreography-Based Saga (for comparison)
// Order Service
on receive(CreateOrderCommand command):
order = createOrder(command)
publish(OrderCreatedEvent(order.id, order.items, order.customerId))
// Inventory Service
on receive(OrderCreatedEvent event):
result = reserveInventory(event.items)
if result.success:
publish(InventoryReservedEvent(event.orderId))
else:
publish(InventoryReservationFailedEvent(event.orderId))
// Payment Service
on receive(InventoryReservedEvent event):
result = processPayment(event.orderId)
if result.success:
publish(PaymentProcessedEvent(event.orderId))
else:
publish(PaymentFailedEvent(event.orderId))
// Shipping Service
on receive(PaymentProcessedEvent event):
result = arrangeShipping(event.orderId)
if result.success:
publish(OrderConfirmedEvent(event.orderId))
else:
publish(ShippingFailedEvent(event.orderId))
// Compensations, triggered by failure events, each handled by whoever owns the data
on receive(InventoryReservationFailedEvent event):
cancelOrder(event.orderId)
on receive(PaymentFailedEvent event):
releaseInventory(event.orderId)
cancelOrder(event.orderId)
on receive(ShippingFailedEvent event):
refundPayment(event.orderId)
releaseInventory(event.orderId)
cancelOrder(event.orderId)One detail both versions share, and one that's easy to miss in pseudo-code: message brokers typically guarantee at-least-once delivery, so every action and every compensation handler needs to be idempotent: safe to run twice without double-charging a card or double-decrementing stock. If you're already using the Transactional Outbox Pattern to publish these events reliably, pairing it with an idempotent consumer on the receiving end is the natural next step, since the outbox's at-least-once delivery guarantee means duplicates are expected, not exceptional.
Alternatives to the Saga Pattern
Two-Phase Commit (2PC)
The coordinator asks every participant to "prepare" (vote yes or no), and only commits once everyone agrees. It gives real atomicity, but the blocking behavior and coordinator single point of failure described earlier make it a poor fit for most microservice stacks. Today it's more commonly seen inside a single database engine than across service boundaries.
TCC (Try-Confirm/Cancel)
Each participant exposes three operations: Try (tentatively reserve a resource, such as holding funds without capturing them), Confirm (finalize the reservation), and Cancel (release it). TCC sits closer to 2PC in feel, more synchronous and immediate than a saga, while still avoiding long-held database locks, since the "reservation" is a business-level concept rather than a database lock. It shows up frequently in payment and e-commerce systems (it's the model behind frameworks like Seata in the Java ecosystem, for example). The catch is that not every operation naturally supports a "tentative reservation" step.
Two-Phase Commit | TCC | Saga | |
|---|---|---|---|
Consistency | Strong, immediate | Strong at the business level | Eventual |
Coupling | Tight, all resources locked together | Medium, needs Try/Confirm/Cancel per service | Loose |
Availability | Lower (blocking, coordinator SPOF) | Medium | Higher |
Undoing failures | Automatic, protocol-driven | Explicit | Explicit compensating transaction |
Best fit | Few participants, short-lived transactions | Resource-reservation-style operations | Long-running processes across many services |
Advantages of the Saga Pattern
Loose coupling. Services don't hold locks on each other's data and don't need to agree on a shared transaction protocol, just on event or command contracts.
Better availability than 2PC. No coordinator blocks the whole system while holding cross-service locks, so individual services keep serving other requests while a saga is in flight.
A natural fit for event-driven architectures. If your services already communicate over a message broker, choreography-based sagas often require little beyond the events you'd want to publish anyway.
Explicit, business-meaningful failure handling. Compensating transactions are just more domain logic (things like "cancel order" or "release inventory"), which is usually easier to reason about than generic rollback semantics.
Centralized visibility with orchestration. A single orchestrator gives you one place to monitor the state of a long-running business process, which pairs well with observability tooling.
Disadvantages and Limitations
Compensating transactions have to be written by hand. For every action you need a corresponding "undo," and some actions (sending a confirmation email, for instance) can't really be undone, only mitigated with a follow-up message.
No true isolation. Because each local transaction commits on its own, other parts of the system can see intermediate states before the saga finishes: something that can't happen inside a single ACID transaction. One common countermeasure is a semantic lock: flagging a record (
status: PENDING, for example) so other processes know to treat it as provisional until the saga completes.Harder to debug and test. The logic for one business process is spread across multiple services, or bundled into an orchestrator, so you need solid distributed tracing with correlation IDs, and you need to test failure at every step, not just the happy path.
Choreography can get hard to follow at scale. With enough services reacting to enough events, there's no single place left to read "the whole story," and it becomes easy to accidentally introduce circular event dependencies.
The orchestrator becomes critical infrastructure. In the orchestration variant, the orchestrator needs its own reliability story: persisted state, versioning for the workflow definition itself, and a plan for what happens if it crashes mid-saga.
Everything needs to be idempotent. At-least-once message delivery is the norm in distributed systems, so both actions and compensations must tolerate being run more than once.
Conclusion
As a rule of thumb: reach for choreography when a saga has just a few steps and you want maximum decoupling; reach for orchestration once a saga grows more steps or gains conditional branching and you need a single place to see and control the process. If the operation genuinely needs strict, immediate atomicity across a small number of resources, it's worth evaluating 2PC or TCC before reaching for a saga. And if the whole operation fits inside one service, skip all of this: a local transaction is all you need.
Whichever coordination style you pick, a saga's reliability depends heavily on how its events get published. That's exactly where the Transactional Outbox Pattern comes in: it guarantees that a local transaction and the event announcing it happened are never separated, which is precisely the assumption every saga step in this post has been quietly relying on.