A saga coordinates a business operation that spans several services by running a sequence of local transactions, one per service. When a step fails, the saga runs compensating transactions that undo the steps that already committed, instead of relying on a distributed rollback. This article walks through the idea with an order workflow and then implements it as an orchestrator on Azure Durable Functions.
Who this is for
You will get the most from this if you run, or are moving toward, services that each own their database, and you need an operation such as "place an order" to end in a consistent state even when one service fails. At the end you will have a step table with compensations, a way to choose between choreography and orchestration, and a working orchestrator you can adapt.
The problem: no shared transaction
Within one service and one database, a transaction gives you ACID guarantees: all operations succeed or none do, data moves from one valid state to another, concurrent transactions behave as if sequential, and committed changes persist.
Microservices typically give each service a dedicated database. According to Microsoft's description of the pattern, this means each service encapsulates its data, can pick the best database technology, can scale independently, and failures are isolated. The cost is that traditional database guarantees do not apply across independently managed data stores. Two-phase commit and tightly coupled interprocess communication are often poor fits, and that is where the saga pattern applies.
How a saga works
Each local transaction in a saga completes its work atomically within one service, updates that service's database, and then triggers the next transaction through an event or message. If one fails, the saga runs compensating transactions to reverse the changes made by the preceding local transactions.
Microsoft's article names three kinds of step:
- Compensable transactions can be undone by another transaction with the opposite effect.
- Pivot transaction is the point of no return. After it succeeds, compensable steps are no longer relevant and all subsequent actions must complete for the system to reach a consistent final state. It can be the last undoable step or the first retryable one.
- Retryable transactions come after the pivot. They are idempotent so the saga can reach its end even through temporary failures.
A worked example: placing an order
Take an order workflow across four services: Orders, Inventory, Payments and Shipping. The business rule is that a customer is charged only if stock is reserved, and an order ships only after payment.
| Step | Service | Local transaction | Compensation | Type |
|---|---|---|---|---|
| 1 | Orders | Create order with status PENDING | Set status CANCELLED | Compensable |
| 2 | Inventory | Reserve stock for the order | Release the reservation | Compensable |
| 3 | Payments | Charge the payment method | Refund the charge | Compensable (last undoable step) |
| 4 | Orders | Set status CONFIRMED | None; retried until it succeeds | Pivot (first retryable step) |
| 5 | Shipping | Create the shipment | None; retried until it succeeds | Retryable |
The ordering matters. Reserving stock first is cheap and fully reversible. Charging a card is the step that is most painful to undo, because a refund is visible to the customer and may involve fees or delay, so it goes as late as possible. Once payment succeeds you commit to finishing: confirming the order and creating the shipment must be retried until they succeed rather than rolled back. Microsoft notes that the pivot can be either the last undoable step or the first retryable one; this example treats confirming the order as the pivot, with the charge as the last step that can still be undone.
The status PENDING also acts as a semantic lock, one of the countermeasures Microsoft lists for the lack of isolation in sagas. Other readers of the order see that it is not final and can treat it accordingly.
Choreography or orchestration
Microsoft describes two ways to coordinate a saga.
| Choreography | Orchestration | |
|---|---|---|
| Control | No central controller; each service publishes domain events that trigger others | A central orchestrator tells participants what to do and tracks state |
| Good for | Simple workflows with few services | Complex workflows or when adding services |
| Benefits | No extra coordination service; no single point of failure | Avoids cyclic dependencies; clear separation of responsibilities |
| Drawbacks | Hard to follow as steps are added; risk of cyclic dependencies; integration testing needs all services | Extra coordination logic; the orchestrator is a point of failure |
For the order example, a choreography would have Orders publish OrderCreated, Inventory react with StockReserved, Payments react with PaymentTaken, and so on, with failure events such as PaymentFailed triggering compensation in the earlier services. That works with four steps and gets harder to reason about with ten. The rest of this article uses orchestration.
Implement the orchestrator with Durable Functions
Durable Functions is an extension of Azure Functions that lets you write orchestrator and activity functions in code. The runtime manages state, checkpoints, retries and recovery so workflows can run reliably for long periods. Error handling and compensation need no new concepts: you use your language's normal try/catch.
The orchestrator below follows the pattern shown in Microsoft's error handling documentation, where a failed credit step triggers a compensating debit, adapted to the order steps. Each activity is a separate function that calls one service and returns.
using Microsoft.Azure.Functions.Worker;
using Microsoft.DurableTask;
public static class PlaceOrderOrchestration
{
[Function(nameof(PlaceOrder))]
public static async Task<string> PlaceOrder(
[OrchestrationTrigger] TaskOrchestrationContext context)
{
var order = context.GetInput<OrderRequest>()!;
// Retries for transient failures, as in the Durable Functions docs.
var retry = TaskOptions.FromRetryPolicy(new RetryPolicy(
maxNumberOfAttempts: 3,
firstRetryInterval: TimeSpan.FromSeconds(5)));
// Step 1: create the order as PENDING
await context.CallActivityAsync("CreateOrder", order, retry);
try
{
// Step 2: reserve stock
await context.CallActivityAsync("ReserveStock", order, retry);
}
catch (TaskFailedException ex)
{
await context.CallActivityAsync("CancelOrder", order, retry);
return $"Order failed at stock reservation: {ex.FailureDetails.ErrorMessage}";
}
try
{
// Step 3: charge the customer
await context.CallActivityAsync("ChargePayment", order, retry);
}
catch (TaskFailedException ex)
{
// Compensate in reverse order
await context.CallActivityAsync("ReleaseStock", order, retry);
await context.CallActivityAsync("CancelOrder", order, retry);
return $"Order failed at payment: {ex.FailureDetails.ErrorMessage}";
}
// Steps 4 and 5 are retryable: after payment there is no rollback.
var untilDone = TaskOptions.FromRetryPolicy(new RetryPolicy(
maxNumberOfAttempts: int.MaxValue,
firstRetryInterval: TimeSpan.FromSeconds(10)));
await context.CallActivityAsync("ConfirmOrder", order, untilDone);
await context.CallActivityAsync("CreateShipment", order, untilDone);
return "Order completed";
}
}Retry policies are documented with maxNumberOfAttempts and firstRetryInterval, plus optional backoffCoefficient, maxRetryInterval and retryTimeout. The documentation also shows a retry policy with maxNumberOfAttempts set to int.MaxValue as a way to retry indefinitely, which is what the post-pivot steps need; combine that with alerting so a permanently failing step does not go unnoticed.
Rules the orchestrator must respect
Orchestrator code is replayed by the runtime, so it must be deterministic. Keep calls to external services, databases, random values and the current time inside activity functions, and use the orchestration context for time and delays. The Durable Functions overview links to the orchestrator code constraints page with the full list.
Make every step idempotent
Retries mean any activity can run more than once. Microsoft stresses that a saga must handle transient failures and idempotence, where repeating an operation does not change the outcome. Practical techniques:
- Pass a stable key such as the order ID in every call and have each service store processed keys.
- Make compensations idempotent too: releasing an already released reservation, or refunding an already refunded charge, should succeed without a second effect.
- Check state before acting, for example "refund only if a charge exists and no refund exists".
For deeper coverage of keys and storage, see Idempotent API Design.
Handle the lack of isolation
Because every participant commits independently, sagas have no built-in isolation. Microsoft lists typical anomalies: lost updates, dirty reads, and fuzzy or nonrepeatable reads. Countermeasures from the same article include:
- Semantic lock: an application-level flag, such as
PENDING, showing an update is in progress. - Commutative updates: design updates so order does not matter.
- Pessimistic view: reorder steps so data updates happen in retryable transactions, avoiding dirty reads.
- Reread values: confirm data is unchanged before updating, and restart if it changed.
- Version files: keep a log of operations on a record and apply them in order.
- Risk-based concurrency: use sagas for low-risk updates and distributed transactions for high-risk ones.
Troubleshooting and limits
A compensation fails. Microsoft notes that compensating transactions might not always succeed, which can leave the system inconsistent. Make compensations retryable, alert when one exhausts its retries, and keep a manual repair path.
Some effects cannot be undone. An email or a physical dispatch has no true inverse. Place such steps after the pivot, or replace the undo with a correcting action such as a follow-up message.
Debugging is hard. Microsoft calls out the difficulty of debugging sagas as services grow. Log the saga or order ID in every service and use Durable Functions instance history to see which step failed.
The orchestrator is a single point of failure. That is a documented drawback of orchestration. Host it on a platform with built-in state recovery, which is what Durable Functions provides, and monitor running instances.
Tightly coupled steps. The pattern may not be suitable when transactions are tightly coupled, compensation would be needed in earlier participants, or there are cyclic dependencies. In those cases reconsider service boundaries.
Checklist
- List each step with its compensation and classify it as compensable, pivot or retryable.
- Put the hardest step to reverse last among the compensable ones.
- Choose choreography for a few simple steps and orchestration for complex flows.
- Make every activity and compensation idempotent.
- Add semantic locks for in-flight state.
- Alert on exhausted retries and on stuck orchestrations.
To publish the events that choreographed sagas depend on, see Transactional Outbox Pattern.
References
- Microsoft Learn, Saga design pattern: https://learn.microsoft.com/en-us/azure/architecture/patterns/saga
- Microsoft Learn, Durable Functions overview: https://learn.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-overview
- Microsoft Learn, Handle errors and retries in Durable Functions: https://learn.microsoft.com/en-us/azure/durable-task/common/durable-task-error-handling
Comments
Questions and tips are welcome. Comments are checked by the EpiFive team before they appear; please do not share passwords, phone numbers or other private details.