Distributed transactions · Microservices · C# / .NET

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.

Updated October 2026Beginner → intermediateAnimated simulator14 real-world scenarios
Create order Reserve stock Charge card Ship order Release stock Cancel order ✕

Open the saga simulatorWhen should I use it?

Chapter 1

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.

Ordersshop DB
Inventoryshop DB
Paymentsshop DB
Shippingshop DB

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.

Chapter 2

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.

Coordinatoridle
Orders DBidle
Inventory DBidle
Payments DBidle
Press Next step to begin.

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.

Chapter 3

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.

T1 Create ordercommitted ✓ T2 Reserve stockcommitted ✓ T3 Charge cardfailed ✕ C2 Release stock C1 Cancel 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

Chapter 4

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.

    Chapter 5 · Hands-on

    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.

    Style
    commandeventsuccess replyfailurecompensationretry / duplicatetimeout

    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.

        Chapter 6

        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.

        AspectOrchestrationChoreography
        Who decides the next stepThe orchestratorEach service, by subscribing to events
        Message styleCommands (send to one queue) + repliesEvents (publish to a topic, many subscribers)
        VisibilityOne place shows the state of every orderState is scattered; you need tracing and correlation IDs
        Adding a stepChange the orchestratorAdd a subscriber — but the flow gets harder to follow
        CouplingOrchestrator knows every participant's contractServices know each other's events; risk of cyclic dependencies
        Single point of failureOrchestrator (mitigate: persist state, run several instances)None central
        TestingTest the state machine in isolationIntegration tests need all services running
        Best forComplex flows, many steps, timeouts, human approvalsSimple flows with 2–4 services and few branches
        .NET toolsMassTransit state machines, NServiceBus sagas, Durable Functions, Dapr Workflow, TemporalPlain 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.

        Chapter 7

        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 stepCompensationNotes
        Create order (PENDING)Mark order CANCELLEDKeep the row for audit; never delete.
        Reserve stockRelease reservationReservations should also expire on their own.
        Authorize cardVoid authorizationCheap and instant, unlike a refund.
        Capture paymentRefundCosts fees and takes days — move capture after the pivot.
        Book flight seatCancel bookingMay carry a cancellation fee: compensation has a business cost.
        Send confirmation emailSend a "sorry, cancelled" emailYou cannot un-send; compensate with a new action.
        Hand parcel to carrierReturn label / recall requestPhysical world — put it after the pivot.
        Award loyalty pointsDeduct the same pointsUse 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).

        Chapter 8

        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.

        Order servicehandles PlaceOrder
        Orders tableorder 1042
        Outbox tablesame transaction
        Outbox relaybackground publisher
        BrokerRabbitMQ / Azure Service Bus
        Inventoryinbox de-duplicates

          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.

          Chapter 9

          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

          CountermeasureIdeaReal-world example
          Semantic lockAn application-level flag says "work in progress".Order status PENDING; the "edit address" endpoint refuses PENDING orders; the warehouse ignores them.
          Commutative updatesUpdates that give the same result in any order.UPDATE stock SET qty = qty - 1 instead of SET qty = 4; loyalty points +100 / −100.
          Pessimistic viewReorder 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 valueCheck 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 fileRecord 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.
          Chapter 10

          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.

          OptionModelState & timersStatus / licensing (Oct 2026)Good fit
          MassTransitState machine saga (MassTransitStateMachine<T>) and routing slips (Courier)EF Core, Marten, MongoDB, Redis, Cosmos DB, DynamoDB…; Schedule for timeoutsv9 is a commercial product (released 2026); v8 stays open source with patches through at least the end of 2026Teams on RabbitMQ / Azure Service Bus / SQS wanting event-driven sagas
          NServiceBusSaga<TData> class with handlersSQL, Cosmos DB, RavenDB, MongoDB, NHibernate…; RequestTimeout; built-in outboxCommercial (Particular Software). v10 requires .NET 10; 10.2 adds built-in AddNServiceBusEndpoint hostingEnterprises wanting a supported platform with monitoring tools
          Azure Durable FunctionsOrchestrator function (workflow-as-code)Event-sourced history; Durable Task Scheduler is the recommended backend; durable timersIsolated 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 WorkflowWorkflow + activities via the Dapr sidecarAny Dapr state store; durable timers; built on Durable TaskDapr.Workflow package; Dapr 1.16+ supports .NET 8 and laterKubernetes / Azure Container Apps, polyglot teams already on Dapr
          TemporalWorkflow class + activities (workflow-as-code)Temporal server or Temporal Cloud; Workflow.DelayAsyncTemporalio .NET SDK is stable (1.x); server is MIT open sourceVery 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

          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.

          Chapter 11

          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.

          Test checklist

          Chapter 12

          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.

          Chapter 13

          Real-world scenarios

          Pick a scenario to see its steps, the compensation for each, where the pivot sits, and which .NET approach fits.

          Chapter 14

          Common mistakes (and the fix)

          Chapter 15

          Glossary

          Chapter 16

          Cheat sheet

          Saga in ten lines

          1. Split the business action into local transactions, one per service.
          2. Give every compensable step an idempotent compensation.
          3. Order steps: compensable → pivot → retryable.
          4. Choose orchestration for complex flows, choreography for simple ones.
          5. Correlate every message with the saga ID.
          6. Use an outbox so state changes and messages commit together.
          7. Make every handler idempotent; expect duplicates and reordering.
          8. Retry transient errors; compensate on business errors.
          9. Add timeouts and expiring reservations.
          10. 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.