Saga Pattern in .NET: Managing Distributed Transactions

Your order service saves the order successfully. A few seconds later the payment service call times out. You are left with a confirmed order, no money charged, and no single transaction you can roll back to fix it.

This happens to every team the moment a business operation gets split across multiple services, each owning its own database. The Saga pattern is the standard way to handle it in .NET microservices. There are two fairly different ways to build one, choreography and orchestration, and this article walks through both with working C# code before getting into where each approach actually breaks down in production.

Why a Single Database Transaction Stops Working

In a monolith, placing an order is one database transaction. You insert the order row, update the inventory count, insert a payment record, and commit. If anything fails partway through, the database rolls back the whole thing and you are left with a clean state.

BEGIN TRANSACTION;
INSERT INTO orders (...) VALUES (...);
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = @id;
INSERT INTO payments (...) VALUES (...);
COMMIT;

This works because ACID guarantees give you atomicity for free. Either all three statements succeed or none of them do, and the database engine handles that guarantee without you writing any extra code for it.

Once orders, inventory, and payments move into separate services, each with its own database, that guarantee disappears. You cannot wrap one transaction around three databases owned by three different services. If the payment fails after the inventory has already been reserved, something has to actively release that reservation, because no database is going to do it for you.

This is exactly the gap the Saga pattern fills.

What a Saga Actually Is

A saga is a sequence of local transactions. Each service runs its own local transaction and then publishes an event or message that triggers the next step. If a step fails, the saga does not roll back in the database sense, it runs compensating transactions that undo the effect of the steps that already succeeded.

The diagram below shows the happy path along with what happens when payment fails: the reserved inventory gets released and the payment gets refunded, both driven by compensating steps rather than a database rollback.

Saga flow: place order, reserve inventory, process payment, confirm order on the happy path; a payment failure triggers compensating steps that refund the payment and release the reserved inventory.
Saga flow: place order, reserve inventory, process payment, confirm order on the happy path; a payment failure triggers compensating steps that refund the payment and release the reserved inventory.

There are two ways to build this: choreography, where services react to each other’s events with no central coordinator, and orchestration, where a dedicated saga orchestrator drives the whole sequence. Picking between them comes down to how many steps you have and how much you value seeing the whole flow in one place.

Choreography: Letting Services React to Events

In choreography, every service listens for the events it cares about and decides on its own what to do next. Nobody is in charge of the flow. The order service does not know or care that an inventory service exists, it just publishes an event and moves on.

Choreography saga: the order service publishes OrderPlaced, inventory reserves stock and publishes InventoryReserved, payment charges and publishes PaymentCompleted, and order confirms, each service reacting to events with no central coordinator.
Choreography saga: the order service publishes OrderPlaced, inventory reserves stock and publishes InventoryReserved, payment charges and publishes PaymentCompleted, and order confirms, each service reacting to events with no central coordinator.

Here is what that looks like end to end for an order, inventory, and payment flow. The order service starts things off:

// Order Service - starts the saga
public class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand, Guid>
{
    private readonly IOrderRepository _repository;
    private readonly IEventBus _eventBus;
 
    public async Task<Result<Guid>> Handle(PlaceOrderCommand command, CancellationToken ct)
    {
        var order = Order.Create(command.CustomerId, command.Items);
        order.SetStatus(OrderStatus.Pending);
 
        _repository.Add(order);
        await _repository.UnitOfWork.SaveChangesAsync(ct);
 
        await _eventBus.PublishAsync(new OrderPlacedEvent(
            order.Id, order.CustomerId, order.Items, order.TotalAmount), ct);
 
        return order.Id;
    }
}

This handler saves the order in a Pending state and publishes OrderPlacedEvent. It does not wait for inventory or payment to respond, it fires the event and returns. That is what keeps the services decoupled, but it also means the order service has no idea whether the rest of the saga eventually succeeds unless something explicitly tells it later.

The inventory service picks up that event next:

// Inventory Service - step 2
public class OrderPlacedEventHandler : IEventHandler<OrderPlacedEvent>
{
    private readonly IInventoryRepository _repository;
    private readonly IEventBus _eventBus;
 
    public async Task Handle(OrderPlacedEvent @event, CancellationToken ct)
    {
        var reservationResult = await _repository.ReserveStockAsync(
            @event.OrderId, @event.Items, ct);
 
        if (reservationResult.IsSuccess)
        {
            await _eventBus.PublishAsync(
                new InventoryReservedEvent(
                    @event.OrderId, @event.Items, @event.TotalAmount), ct);
        }
        else
        {
            await _eventBus.PublishAsync(
                new InventoryReservationFailedEvent(
                    @event.OrderId, reservationResult.Error), ct);
        }
    }
}

A successful reservation publishes InventoryReservedEvent so payment can proceed. A failed one, say the stock ran out between checkout and this handler running, publishes InventoryReservationFailedEvent instead, and nothing downstream of the payment step ever fires, which is exactly what you want.

Payment reacts to the inventory event:

// Payment Service - step 3
public class InventoryReservedEventHandler : IEventHandler<InventoryReservedEvent>
{
    private readonly IPaymentService _paymentService;
    private readonly IEventBus _eventBus;
 
    public async Task Handle(InventoryReservedEvent @event, CancellationToken ct)
    {
        var paymentResult = await _paymentService.ChargeAsync(
            @event.OrderId, @event.TotalAmount, ct);
 
        if (paymentResult.IsSuccess)
        {
            await _eventBus.PublishAsync(
                new PaymentCompletedEvent(@event.OrderId, paymentResult.PaymentId), ct);
        }
        else
        {
            // Compensate: release inventory
            await _eventBus.PublishAsync(
                new PaymentFailedEvent(@event.OrderId, paymentResult.Error), ct);
        }
    }
}

A successful charge publishes PaymentCompletedEvent, which a handler elsewhere turns into an order confirmation. A failed charge publishes PaymentFailedEvent instead, and this is where the compensating action kicks in.

The inventory service has to listen for that failure too, because it is the one holding the reservation that now needs releasing:

// Inventory Service - compensation
public class PaymentFailedEventHandler : IEventHandler<PaymentFailedEvent>
{
    private readonly IInventoryRepository _repository;
 
    public async Task Handle(PaymentFailedEvent @event, CancellationToken ct)
    {
        // Compensating action: release reserved stock
        await _repository.ReleaseStockAsync(@event.OrderId, ct);
    }
}

This handler is the compensating transaction for the reservation made earlier. It does not roll back a database transaction, it runs a new operation that undoes the old one on purpose. That distinction matters once you start reasoning about sagas: compensation is business logic you write yourself, not something the message bus or the database gives you for free.

Where Choreography Holds Up and Where It Does Not

Choreography works well when you have two or three steps and the services do not really need to know about each other. It stays loosely coupled because every service only cares about the events it subscribes to, and there is no orchestrator that can become a bottleneck or a single point of failure.

  • Straightforward to implement for two or three steps
  • Loosely coupled, since services only react to events they care about
  • No single component that the rest of the flow depends on

The costs show up as the saga grows.

  • The full flow is scattered across every service involved, so no one place shows the whole sequence
  • Adding a new step usually means touching several services instead of one
  • Testing the complete saga needs all participating services running together
  • It is easy to accidentally create a cycle where two services end up reacting to each other’s events

I have seen choreographed sagas grow to five or six steps and turn into systems where nobody on the team can confidently say what happens when a particular event fires, because tracing it means opening four different codebases. That is usually the point to switch to orchestration.

Orchestration: A Central Coordinator Drives the Flow

Orchestration puts a dedicated saga orchestrator in charge. It sends commands to each service and reacts to their responses, so the entire sequence lives in one place instead of being spread across handlers in different services.

Orchestration state machine: OrderPlaced moves to AwaitingInventory, then to AwaitingPayment once inventory is reserved, then to Completed on payment, with failure paths routing through Compensating to the Failed state.
Orchestration state machine: OrderPlaced moves to AwaitingInventory, then to AwaitingPayment once inventory is reserved, then to Completed on payment, with failure paths routing through Compensating to the Failed state.

MassTransit’s state machine sagas are the most common way to build an orchestrator in .NET. Here is the core of one for the same order flow:

public class OrderSaga : MassTransitStateMachine<OrderSagaState>
{
    public OrderSaga()
    {
        InstanceState(x => x.CurrentState);
 
        Event(() => OrderPlaced, x => x.CorrelateById(c => c.Message.OrderId));
        Event(() => InventoryReserved, x => x.CorrelateById(c => c.Message.OrderId));
        Event(() => InventoryReservationFailed, x => x.CorrelateById(c => c.Message.OrderId));
        Event(() => PaymentCompleted, x => x.CorrelateById(c => c.Message.OrderId));
        Event(() => PaymentFailed, x => x.CorrelateById(c => c.Message.OrderId));
        Event(() => InventoryReleased, x => x.CorrelateById(c => c.Message.OrderId));
 
        Initially(
            When(OrderPlaced)
                .Then(context =>
                {
                    context.Saga.OrderId = context.Message.OrderId;
                    context.Saga.CustomerId = context.Message.CustomerId;
                    context.Saga.Items = context.Message.Items;
                    context.Saga.TotalAmount = context.Message.TotalAmount;
                })
                .Send(context => new ReserveInventoryCommand(
                    context.Saga.OrderId,
                    context.Saga.Items))
                .TransitionTo(AwaitingInventory));
 
        During(AwaitingInventory,
            When(InventoryReserved)
                .Send(context => new ProcessPaymentCommand(
                    context.Saga.OrderId,
                    context.Saga.TotalAmount))
                .TransitionTo(AwaitingPayment),
            When(InventoryReservationFailed)
                .Send(context => new CancelOrderCommand(
                    context.Saga.OrderId,
                    "Insufficient inventory"))
                .TransitionTo(Failed)
                .Finalize());
 
        During(AwaitingPayment,
            When(PaymentCompleted)
                .Send(context => new ConfirmOrderCommand(context.Saga.OrderId))
                .TransitionTo(Completed)
                .Finalize(),
            When(PaymentFailed)
                .Send(context => new ReleaseInventoryCommand(
                    context.Saga.OrderId,
                    context.Saga.Items))
                .Send(context => new CancelOrderCommand(
                    context.Saga.OrderId,
                    "Payment failed"))
                .TransitionTo(Compensating));
 
        During(Compensating,
            When(InventoryReleased)
                .TransitionTo(Failed)
                .Finalize());
    }
 
    public State AwaitingInventory { get; private set; }
    public State AwaitingPayment { get; private set; }
    public State Compensating { get; private set; }
    public State Completed { get; private set; }
    public State Failed { get; private set; }
 
    public Event<OrderPlacedEvent> OrderPlaced { get; private set; }
    public Event<InventoryReservedEvent> InventoryReserved { get; private set; }
    public Event<InventoryReservationFailedEvent> InventoryReservationFailed { get; private set; }
    public Event<PaymentCompletedEvent> PaymentCompleted { get; private set; }
    public Event<PaymentFailedEvent> PaymentFailed { get; private set; }
    public Event<InventoryReleasedEvent> InventoryReleased { get; private set; }
}

This state machine starts in the Initially block when OrderPlaced arrives, saves the order details onto the saga state, sends ReserveInventoryCommand, and transitions to AwaitingInventory. Every transition after that is explicit: During(AwaitingInventory, …) tells you exactly what can happen next, InventoryReserved moves things forward to AwaitingPayment, and InventoryReservationFailed moves straight to Failed. You can read the whole saga top to bottom and know every path it can take, which is what you are trading choreography’s simplicity for.

The Compensating state deserves a closer look. When PaymentFailed arrives while AwaitingPayment, the saga sends both ReleaseInventoryCommand and CancelOrderCommand before moving to Compensating, and only finalizes once InventoryReleased confirms the compensation actually happened. That is deliberate. You do not want to mark a saga Failed and move on until the compensating action has actually completed, otherwise you can end up with orphaned reservations sitting in the inventory service.

The saga needs somewhere to persist its state between steps, since a real order can take minutes to complete and the process might restart in between:

public class OrderSagaState : SagaStateMachineInstance
{
    public Guid CorrelationId { get; set; }
    public string CurrentState { get; set; }
    public Guid OrderId { get; set; }
    public Guid CustomerId { get; set; }
    public List<OrderItem> Items { get; set; }
    public decimal TotalAmount { get; set; }
}

MassTransit persists this to whatever repository you configure, Entity Framework against a relational database in this example, so CurrentState and the correlation data survive process restarts. Without this persistence, an app restart mid saga would lose track of where the order was, which is a real production failure mode worth testing on purpose rather than assuming saga persistence just works.

One detail people miss when they first set up MassTransit: the Send calls in the state machine need to know which queue to send to, and that has to be registered separately:

EndpointConvention.Map<ReserveInventoryCommand>(new Uri("queue:reserve-inventory"));
EndpointConvention.Map<ProcessPaymentCommand>(new Uri("queue:process-payment"));
EndpointConvention.Map<ReleaseInventoryCommand>(new Uri("queue:release-inventory"));
EndpointConvention.Map<ConfirmOrderCommand>(new Uri("queue:confirm-order"));
EndpointConvention.Map<CancelOrderCommand>(new Uri("queue:cancel-order"));
 
builder.Services.AddMassTransit(x =>
{
    x.AddSagaStateMachine<OrderSaga, OrderSagaState>()
        .EntityFrameworkRepository(r =>
        {
            r.ExistingDbContext<SagaDbContext>();
            r.UsePostgres();
        });
 
    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.ConfigureEndpoints(context);
    });
});

EndpointConvention.Map ties a command type to a queue name once, at startup, so the saga’s Send calls know where messages should land. Skip this registration and MassTransit throws at runtime the first time the saga tries to send that command, usually well after you thought the wiring was done. It is worth checking this list against every command your state machine sends before you consider the setup complete.

Where Orchestration Holds Up and Where It Does Not

  • The entire flow lives in one place, so you can read the whole sequence without jumping between services
  • Adding or reordering steps means changing one state machine, not several handlers
  • Compensation logic is centralized instead of scattered across services
  • You can unit test the orchestrator on its own, without spinning up every downstream service

The trade-off is that you now have one component the whole saga depends on.

  • The orchestrator becomes a single point of failure for that business process
  • Large sagas can turn the state machine itself into a fairly complex piece of code
  • You need extra infrastructure just for the orchestrator: a state machine, persistence, and the queue wiring shown above

Deciding Between the Two

  • Best for: choreography suits two or three simple steps, orchestration suits complex multi-step flows
  • Coupling: choreography is loosely coupled through events, orchestration adds moderate coupling through commands
  • Visibility: choreographed flows are hard to trace across services, an orchestrator shows the whole flow in one place
  • Adding steps: choreography needs new event handlers in multiple services, orchestration means modifying one state machine
  • Testing: choreography needs integration tests spanning services, an orchestrator can be unit tested in isolation
  • Error handling: distributed across services in choreography, centralized in the orchestrator

Use choreography for something like order confirmation triggering a notification, two steps with nothing worth centralizing. Reach for orchestration the moment you have more than two steps or compensation that needs to happen in a specific order. In my experience that covers most business critical checkout, fulfilment, or provisioning flows, so orchestration ends up being the default for anything customer facing.

Idempotency Is Not Optional

Message queues deliver at least once, not exactly once, so every saga step has to produce the same result whether it runs once or five times for the same message. This is not specific to sagas, it comes with using message queues in general, but it bites hardest in sagas because a duplicate delivery halfway through one can leave you with double reserved inventory or a duplicate charge.

public class ReserveInventoryCommandHandler
{
    public async Task Handle(ReserveInventoryCommand command, CancellationToken ct)
    {
        // Check if already reserved (idempotent)
        var existing = await _repository.GetReservationAsync(command.OrderId, ct);
        if (existing is not null)
        {
            return; // Already processed, skip
        }
 
        await _repository.ReserveStockAsync(command.OrderId, command.Items, ct);
    }
}

This handler checks for an existing reservation before creating a new one, so a redelivered ReserveInventoryCommand becomes a no-op instead of reserving stock twice. The check by itself is not safe under concurrent execution, a plain read followed by an insert has a race window between two overlapping deliveries. In production I back this with a unique constraint on the order ID in the reservations table and treat the resulting constraint violation as already handled, rather than trusting the earlier read alone.

When a Saga Is the Wrong Tool

Before reaching for a saga, check whether you actually have a distributed transaction problem.

  • If all the data lives in one database, use a regular transaction. A saga adds more code for weaker guarantees than what you already have.
  • If you control the service boundaries, consider whether merging the services removes the problem entirely. The cheapest distributed transaction is the one you avoid needing.
  • If there is nothing to compensate, plain pub or sub between services is enough. Sagas earn their complexity when a failed step actually requires undoing earlier work.

Sagas also expose intermediate states to whoever is watching the process, whether that is a customer or a support engineer. An order can sit in pending payment for anywhere from a second to several minutes, and your UI, support tooling, and alerting all need to account for that state existing. This is the same trade-off you hit with the outbox pattern versus a true distributed transaction, you are choosing availability and eventual consistency over strict atomicity, and that choice has to be visible to the rest of the system rather than hidden inside the saga.

Making the Workflow Explicit

Whichever approach you pick, write down every local transaction, compensating action, timeout, and terminal state as part of the design before coding the handlers. Stick with choreography only while the flow stays simple enough for a new team member to reconstruct it by reading two or three services. The moment that stops being true, move to orchestration so the state and the recovery logic live in one place.

Make every step idempotent from day one, not as an afterthought once a duplicate delivery bug shows up in production, and publish state transitions through the outbox pattern so a crash between the database write and the message publish cannot leave the saga stuck. That combination, together with the choreography versus orchestration decision, is what actually makes a saga reliable enough to run real checkout or fulfilment flows.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading