Saga Pattern in C# .NET
Keep data consistent across microservices without a distributed transaction. Follow one e-commerce order from checkout to delivery, break it on purpose, and watch the saga put things right — step by step, with real C# for MassTransit, NServiceBus, Azure Durable Functions, Dapr Workflow and Temporal.
The problem: one business action, many databases
A customer clicks Place order. Behind that one click, four things must happen: the order is recorded, stock is reserved, the card is charged and a shipment is booked. Either all of it should happen, or none of it should stick.
ACID — and what we lose across services
A Atomicity
All operations succeed or none do. A saga gives you semantic atomicity: either every step completes, or completed steps are compensated.
C Consistency
Data moves from one valid state to another. With sagas it becomes eventual consistency: valid at the end, not at every instant.
I Isolation
Concurrent transactions look sequential. Sagas have no isolation — other requests can see half-finished work. Chapter 9 shows how to cope.
D Durability
Committed changes survive crashes. Each saga step commits in its own database, and the saga's own state is persisted too.
Why not just call the four APIs one after another over HTTP? If the payment call times out after stock was reserved, who releases the stock? If your process crashes between "save order" and "call inventory", nobody remembers the order exists. A saga is the disciplined answer to exactly these half-finished states.
Why not a distributed transaction (two-phase commit)?
Two-phase commit (2PC) uses a coordinator. Phase 1 asks every participant to prepare (lock rows and promise to commit). Phase 2 tells everyone to commit. It is truly atomic — but step through it and turn on the crash to see the cost.
Problems with 2PC in microservices
- Blocking: participants hold locks while they wait for the coordinator. A crash leaves them "in doubt".
- Availability multiplies: every participant must be up. Four services at 99.9% each give roughly 99.6% for the whole transaction.
- Not supported everywhere: message brokers, most NoSQL stores and SaaS APIs (payment gateways, carriers) do not take part in XA/2PC.
- Latency and coupling: every request waits for the slowest participant.
.NET note
System.Transactions can escalate a TransactionScope to a distributed transaction through MSDTC. Since .NET 7 this works again, but only on Windows and only when you opt in with TransactionManager.ImplicitDistributedTransactions = true. Linux containers, Azure SQL elastic pools and cloud brokers make it a poor fit for microservices.
2PC is still fine inside one database server or for a few high-risk operations. Sagas trade isolation for availability and loose coupling.
What is a saga?
A saga is a sequence of local transactions. Each step updates one service's own database atomically, then triggers the next step with a message or event. If a step fails, the saga runs compensating transactions that undo the effect of the steps that already succeeded — usually in reverse order.
An everyday analogy
You plan a trip: you book a flight, then a hotel, then a rental car. The car company has nothing left. You don't "un-happen" the flight — you cancel it (maybe paying a fee) and cancel the hotel. Those cancellations are compensations: new actions that semantically undo earlier ones.
Where the name comes from
The idea was published in 1987 by Hector Garcia-Molina and Kenneth Salem in the paper "Sagas", to break long-lived database transactions into smaller ones that do not hold locks for hours. Microservices brought it back because each service owns its own data.
The rules in one list
- Every step is a local ACID transaction in exactly one service.
- Every step that can be undone has a compensating transaction defined up front.
- Steps communicate asynchronously through commands and events on a reliable message broker (or a durable workflow engine).
- Every message carries a correlation ID (usually the order ID) that ties it to one saga instance.
- Handlers are idempotent, because messages can arrive more than once.
- The system is eventually consistent, and intermediate states are visible to others.
Compensable, pivot and retryable transactions
Not all steps are equal. Microsoft's Azure Architecture Center sorts them into three kinds, and their order matters. Tap each step of our order saga.
Ccompensable — can be undonePpivot — point of no returnRretryable — must eventually succeed
CCompensable
Comes first. Each has an opposite action: reserve ↔ release, authorize ↔ void, create ↔ cancel. If anything before the pivot fails, these are undone in reverse.
PPivot
The go/no-go decision. Once it succeeds the saga never goes back; it can only go forward. It may be the last compensable step, the first retryable one, or a step that can be neither undone nor retried.
RRetryable
Come after the pivot. They must be idempotent and are retried (with backoff) until they succeed, so the saga always reaches its final state.
Design tip: put steps that are likely to fail (validation, stock checks, credit checks) early, before the pivot, where failing is cheap. Put steps that are hard to undo (shipping a parcel, sending an email, paying out money) after the pivot, where they only need to be retried.
Saga simulator: order #1042
Choose a coordination style and a failure, then press Play or go one message at a time with Step. Watch the messages travel, the saga state change, the compensation stack fill and empty, and the log explain every move.
Saga state (orchestrator)
Compensation stack (last in, first out)
What is happening
Message log
Tip: run the same failure in both styles. In orchestration one component knows the whole story; in choreography every service reacts to events and nobody holds the full picture.
Orchestration vs choreography
🎼 Orchestration
A central orchestrator (a saga state machine or a durable workflow) tells each service what to do with commands and reacts to their replies. It stores the saga state and runs compensations.
Like a conductor: the musicians only play when told.
💃 Choreography
No central controller. Each service does its local transaction and publishes an event; other services subscribe and react, including with compensations.
Like dancers who know the routine and react to each other.
| Aspect | Orchestration | Choreography |
|---|---|---|
| Who decides the next step | The orchestrator | Each service, by subscribing to events |
| Message style | Commands (send to one queue) + replies | Events (publish to a topic, many subscribers) |
| Visibility | One place shows the state of every order | State is scattered; you need tracing and correlation IDs |
| Adding a step | Change the orchestrator | Add a subscriber — but the flow gets harder to follow |
| Coupling | Orchestrator knows every participant's contract | Services know each other's events; risk of cyclic dependencies |
| Single point of failure | Orchestrator (mitigate: persist state, run several instances) | None central |
| Testing | Test the state machine in isolation | Integration tests need all services running |
| Best for | Complex flows, many steps, timeouts, human approvals | Simple flows with 2–4 services and few branches |
| .NET tools | MassTransit state machines, NServiceBus sagas, Durable Functions, Dapr Workflow, Temporal | Plain consumers/handlers on MassTransit, NServiceBus, Wolverine, Rebus, Azure Service Bus SDK |
Hybrid is normal. Teams often orchestrate the steps inside one bounded context (for example, the Ordering team owns an order saga) and use choreography between contexts (Ordering publishes OrderApproved; Loyalty and Analytics react to it). Avoid "event spaghetti": if you need a whiteboard to explain who reacts to what, switch that flow to orchestration.
Commands vs events
command ReserveStock
Imperative, sent to one owner, can be rejected. Named as a verb. In MassTransit: Send; in NServiceBus: context.Send with ICommand.
event StockReserved
A fact that already happened, published to any number of subscribers, cannot be rejected. Named in the past tense. MassTransit: Publish; NServiceBus: context.Publish with IEvent.
Designing compensating transactions
A compensation is not a database rollback. The original change was committed and others may have seen it. A compensation is a new business action that semantically cancels the first one.
| Forward step | Compensation | Notes |
|---|---|---|
| Create order (PENDING) | Mark order CANCELLED | Keep the row for audit; never delete. |
| Reserve stock | Release reservation | Reservations should also expire on their own. |
| Authorize card | Void authorization | Cheap and instant, unlike a refund. |
| Capture payment | Refund | Costs fees and takes days — move capture after the pivot. |
| Book flight seat | Cancel booking | May carry a cancellation fee: compensation has a business cost. |
| Send confirmation email | Send a "sorry, cancelled" email | You cannot un-send; compensate with a new action. |
| Hand parcel to carrier | Return label / recall request | Physical world — put it after the pivot. |
| Award loyalty points | Deduct the same points | Use commutative updates (+/−), not "set to". |
Rules every compensation must follow
Idempotent
Releasing the same reservation twice must be harmless. Key it on the order ID or reservation ID.
Retryable until it succeeds
A compensation must not "give up". Retry with backoff; if it still fails, move it to a dead-letter queue and alert a human.
Works when the outcome is unknown
After a timeout you don't know whether the step happened. "Void payment for order 1042" must work whether or not an authorization exists.
Handles out-of-order arrival
ReleaseStock can arrive before ReserveStock was processed. Record a tombstone so the late reserve is ignored.
Stores what it needs
Save the data needed to undo (reservation ID, authorization ID) in the saga state or the step's log.
Reverse order — or parallel
Undo in LIFO order by default; independent compensations (release stock, void payment) can run in parallel.
The compensation stack in C#
Workflow-as-code engines (Durable Functions, Dapr Workflow, Temporal) usually implement this with a stack of undo actions. Temporal's docs recommend registering the compensation before calling the step, because a step can succeed and still look like a failure (the reply was lost).
The reliability toolkit every saga needs
A saga is only as reliable as its messaging. These building blocks turn "usually works" into "always ends in a consistent state".
Transactional outbox (and inbox) — try to break it
The classic bug is the dual write: you save to your database and then publish to the broker. If the process dies in between, the order exists but nobody hears about it. The outbox writes the message into the same database transaction; a relay publishes it later.
MassTransit (AddEntityFrameworkOutbox + UseBusOutbox), NServiceBus (EnableOutbox()), Wolverine and Dapr (outbox for state + pub/sub) all ship this. Durable Functions, Dapr Workflow and Temporal avoid the problem inside the workflow by persisting history, but your activities still need idempotent writes.
Idempotent consumers
Brokers guarantee at-least-once delivery, so duplicates will happen. Make every handler safe to run twice: check a processed-messages table (inbox), use a natural key (order ID), or use the payment provider's idempotency key.
Retries: transient vs permanent
Retry transient errors (timeouts, 503, deadlocks) with exponential backoff and jitter. Never retry business failures (card declined, out of stock) — those trigger compensation. Mark them non-retryable.
Timeouts & deadlines
Silence is a failure too. Schedule a timeout message (MassTransit Schedule, NServiceBus RequestTimeout) or a durable timer. Give reservations an expiry so a dead saga can't hold stock forever.
Correlation & tracing
Every message carries the saga's correlation ID (order ID). Propagate W3C trace context with OpenTelemetry — all five .NET options emit OTel traces, so one order becomes one trace across services.
Saga state & concurrency
Two replies can arrive at the same time for the same saga. Persist saga state with optimistic concurrency (row version / ETag) and let the losing message retry. NServiceBus does this for you; in MassTransit set ConcurrencyMode.
Poison messages & DLQ
After retries are exhausted, move the message to an error / dead-letter queue, alert, and provide a way to replay it after a fix (ServicePulse for NServiceBus, Azure Service Bus DLQ, RabbitMQ _error queues).
Ordering is not guaranteed
Design for messages arriving out of order: check the saga's current state before acting, ignore events that are no longer relevant, and use sessions/partitions only where ordering truly matters.
Versioning long-running sagas
Sagas can live for days. When you deploy a new version, old instances are still running. Keep message contracts backward compatible; in workflow engines respect determinism (Temporal patching, new orchestration names in Durable Functions).
Observability
Expose saga status: query the saga table, use Durable Functions custom status and the Durable Task Scheduler dashboard, the Temporal Web UI, Dapr workflow status API, or ServiceInsight. Alert on sagas stuck in one state too long.
Lack of isolation: anomalies and countermeasures
Because each step commits immediately, other requests and other sagas can see — and act on — half-finished work. These are the typical anomalies:
Lost update
One saga overwrites a change made by another. Example: saga A sets stock to 4 while saga B, which read 5 earlier, also sets it to 4 — one sale disappears.
Dirty read
Reading a change that will later be compensated. Example: a credit-limit check sees a reservation that is about to be released and wrongly rejects another order.
Fuzzy (non-repeatable) read
Two steps of one saga read the same data and get different values. Example: the price is read at checkout and again at capture, and a promotion ended in between.
Countermeasures
| Countermeasure | Idea | Real-world example |
|---|---|---|
| Semantic lock | An application-level flag says "work in progress". | Order status PENDING; the "edit address" endpoint refuses PENDING orders; the warehouse ignores them. |
| Commutative updates | Updates that give the same result in any order. | UPDATE stock SET qty = qty - 1 instead of SET qty = 4; loyalty points +100 / −100. |
| Pessimistic view | Reorder steps so risky updates happen in retryable steps after the pivot. | Increase a customer's available credit only after the order is final, so nobody reads credit that might be taken back. |
| Reread value | Check the data hasn't changed before updating (optimistic concurrency); restart if it has. | Re-check the price/version at capture; EF Core [Timestamp] row version throws DbUpdateConcurrencyException. |
| Version file | Record operations so they can be applied in the right order even if they arrive out of order. | CancelOrder arrives before CreateOrder: the log records it, and the late create is turned into a no-op. |
| By value (risk-based) | Pick the concurrency mechanism by business risk. | Transfers under $10,000 run as sagas; larger ones use a stricter process or manual review. |
Implementing sagas in C# .NET (October 2026)
There are two families. Message-driven sagas (MassTransit, NServiceBus) persist a state object and react to messages. Durable execution / workflow-as-code engines (Durable Functions, Dapr Workflow, Temporal) let you write the saga as ordinary async C#; the engine records history and replays your code after a crash.
| Option | Model | State & timers | Status / licensing (Oct 2026) | Good fit |
|---|---|---|---|---|
| MassTransit | State machine saga (MassTransitStateMachine<T>) and routing slips (Courier) | EF Core, Marten, MongoDB, Redis, Cosmos DB, DynamoDB…; Schedule for timeouts | v9 is a commercial product (released 2026); v8 stays open source with patches through at least the end of 2026 | Teams on RabbitMQ / Azure Service Bus / SQS wanting event-driven sagas |
| NServiceBus | Saga<TData> class with handlers | SQL, Cosmos DB, RavenDB, MongoDB, NHibernate…; RequestTimeout; built-in outbox | Commercial (Particular Software). v10 requires .NET 10; 10.2 adds built-in AddNServiceBusEndpoint hosting | Enterprises wanting a supported platform with monitoring tools |
| Azure Durable Functions | Orchestrator function (workflow-as-code) | Event-sourced history; Durable Task Scheduler is the recommended backend; durable timers | Isolated worker model is the default for new apps; Durable Task Scheduler Dedicated GA (Nov 2025), Consumption GA (2026) | Azure-native, serverless, long-running and human-in-the-loop flows |
| Dapr Workflow | Workflow + activities via the Dapr sidecar | Any Dapr state store; durable timers; built on Durable Task | Dapr.Workflow package; Dapr 1.16+ supports .NET 8 and later | Kubernetes / Azure Container Apps, polyglot teams already on Dapr |
| Temporal | Workflow class + activities (workflow-as-code) | Temporal server or Temporal Cloud; Workflow.DelayAsync | Temporalio .NET SDK is stable (1.x); server is MIT open source | Very long-running, high-scale, multi-cloud workflows |
Also worth knowing: Wolverine (JasperFx, open source) has sagas with a built-in outbox; Rebus has open-source sagas. Always check the current license of a library before you commit to it.
Shared message contracts
The order saga, five ways
A state machine: Initially / During blocks map events to actions and transitions. The saga instance is persisted with optimistic concurrency; the bus outbox makes "update state + send message" atomic.
Registration (outbox, retries, scheduler)
A participant: idempotent consumer
A saga class per process. ConfigureHowToFindSaga maps each message to the saga by OrderId; Data is persisted automatically, and NServiceBus handles concurrency and (with the outbox) exactly-once side effects.
Endpoint configuration (.NET 10, NServiceBus 10.2+)
Isolated worker model with Microsoft.Azure.Functions.Worker.Extensions.DurableTask. The orchestrator must be deterministic: no DateTime.Now, Guid.NewGuid(), I/O or Task.Delay — use context.CurrentUtcDateTime, context.NewGuid(), activities and durable timers.
Timeout: race an external event against a durable timer
Dapr Workflow runs inside your app and talks to the Dapr sidecar, which persists history in the configured state store. The same determinism rules apply to the workflow class.
Temporal workflows are classes marked [Workflow] with a [WorkflowRun] entry point. Use Workflow.DelayAsync, Workflow.UtcNow and Workflow.Random instead of .NET timers, clocks and randomness.
Alternative: MassTransit routing slip (Courier)
A routing slip carries its own itinerary of activities. Each activity has Execute and Compensate; when one faults, Courier automatically compensates the earlier activities that logged compensation data. Great for a fixed sequence of steps; combine with a state machine saga to monitor it.
Testing sagas
Test the failure paths more than the happy path: every failure point, every timeout, duplicates and out-of-order messages. Each framework ships a harness that runs your saga in memory.
Orchestrators take a context object, so you can unit-test the compensation logic by mocking it (shown with Moq). For end-to-end tests, run the Durable Task Scheduler emulator or dapr init in a container.
Test checklist
- Fail each step in turn; assert exactly the right compensations ran, in the right order.
- Fire the timeout; assert the saga compensates and ignores the late reply.
- Deliver every message twice; assert no double charge or double reservation.
- Deliver replies out of order; assert the state machine stays valid.
- Make a compensation fail; assert it is retried and ends in the error queue with an alert.
- Restart the host mid-saga (integration test); assert it resumes.
When to use a saga — and when not to
✅ Use a saga when
- One business operation spans several services that own their data (database per service).
- You need to undo or compensate earlier steps if a later one fails.
- Some participants are external systems (payment gateway, airline, carrier) that can't join a 2PC transaction.
- The process is long-running: minutes, days, or waits for a human approval or a timeout.
- Availability and loose coupling matter more than immediate consistency.
- The business accepts eventual consistency and visible intermediate states ("order pending").
❌ Don't use a saga when
- Everything lives in one database — use a normal local transaction (a modular monolith often is enough).
- Steps are tightly coupled and need strict isolation (e.g., double-entry ledger lines that must never be seen half-written).
- Compensation is impossible or unacceptable and the step can't be moved after a pivot.
- You only need to notify others reliably, with no rollback — a transactional outbox + events is enough.
- There are cyclic dependencies between participants, or compensations would have to run in services before the failing one in complex ways — rethink the service boundaries first.
- The team lacks the observability and operational maturity to run asynchronous messaging yet.
Alternatives and related patterns
Local transaction
If the data can live in one database, a single SaveChangesAsync beats any saga.
Transactional outbox only
Commit + reliably publish an event; consumers never need to undo anything.
TCC (Try-Confirm/Cancel)
Each participant tries (reserves), then all are confirmed or cancelled. A reservation-based cousin of the saga.
Reservation pattern
Hold resources with an expiry (seats, stock, funds) so failures clean themselves up.
Process manager / routing slip
A process manager is a stateful router — what MassTransit and NServiceBus call a saga. A routing slip carries its own itinerary.
2PC / distributed transaction
For a few high-risk operations inside infrastructure that supports it — the "by value" countermeasure.
Real-world scenarios
Pick a scenario to see its steps, the compensation for each, where the pivot sits, and which .NET approach fits.
Common mistakes (and the fix)
Glossary
Cheat sheet
Saga in ten lines
- Split the business action into local transactions, one per service.
- Give every compensable step an idempotent compensation.
- Order steps: compensable → pivot → retryable.
- Choose orchestration for complex flows, choreography for simple ones.
- Correlate every message with the saga ID.
- Use an outbox so state changes and messages commit together.
- Make every handler idempotent; expect duplicates and reordering.
- Retry transient errors; compensate on business errors.
- Add timeouts and expiring reservations.
- Trace, monitor, alert on stuck sagas, and test every failure path.
Library versions and licenses were checked in October 2026; always confirm them before choosing a framework.