Learnixo
Back to blog
Backend Systemsadvanced

Saga Pattern in .NET — Choreography vs Orchestration

Implement distributed transactions in .NET using the Saga pattern: choreography with MassTransit events, orchestration with state machines, compensating transactions, and failure recovery.

Asma Hafeez KhanMay 25, 20267 min read
.NETC#SagaMassTransitdistributed systemsmicroservicestransactions
Share:𝕏

Saga Pattern in .NET — Choreography vs Orchestration

Microservices don't share a database, so ACID transactions across services are impossible. The Saga pattern breaks a distributed transaction into a sequence of local transactions with compensating actions to undo completed steps when a later step fails.


The Problem

Placing an order touches three services:
  1. Order Service    — creates the order record
  2. Payment Service  — charges the customer
  3. Inventory Service — reserves the stock

If step 3 fails after steps 1 and 2 succeeded:
  - Database rollback: impossible (different databases)
  - Leave inconsistent state: unacceptable
  - Saga: compensate by reversing steps 2 and 1 in order

Choreography vs Orchestration

Choreography — event-driven, no central coordinator:
  OrderService publishes OrderPlaced
    → PaymentService subscribes, charges card, publishes PaymentProcessed
      → InventoryService subscribes, reserves stock, publishes StockReserved
  
  Pros: loose coupling, no single point of failure
  Cons: hard to visualise flow, distributed debugging nightmare

Orchestration — central saga manager coordinates services:
  SagaOrchestrator sends PlaceOrderCommand
    → tells PaymentService to charge card
    → tells InventoryService to reserve stock
    → if any step fails, sends compensating commands
  
  Pros: single place to see the entire flow, easy to audit
  Cons: orchestrator becomes a bottleneck if not stateless

Step 1: Saga State Machine with MassTransit

C#
// Install MassTransit with RabbitMQ and EF Core persistence
// <PackageReference Include="MassTransit.RabbitMQ" />
// <PackageReference Include="MassTransit.EntityFrameworkCore" />

// Saga state — persisted to database
public class OrderSagaState : SagaStateMachineInstance
{
    public Guid CorrelationId  { get; set; }   // links all messages in this saga
    public string CurrentState { get; set; } = "";

    // Saga data accumulated as steps complete
    public int     OrderId        { get; set; }
    public int     CustomerId     { get; set; }
    public decimal OrderTotal     { get; set; }
    public string? PaymentRef     { get; set; }
    public string? ReservationRef { get; set; }
    public string? FailureReason  { get; set; }

    // Timeout scheduling
    public Guid? PaymentTimeoutToken    { get; set; }
    public Guid? InventoryTimeoutToken  { get; set; }
}
C#
// The state machine — defines all states, events, and transitions
public class OrderSaga : MassTransitStateMachine<OrderSagaState>
{
    // States
    public State AwaitingPayment   { get; private set; } = null!;
    public State AwaitingInventory { get; private set; } = null!;
    public State Completed         { get; private set; } = null!;
    public State Compensating      { get; private set; } = null!;
    public State Failed            { get; private set; } = null!;

    // Events (messages that drive the saga)
    public Event<OrderPlaced>           OrderPlaced           { get; private set; } = null!;
    public Event<PaymentProcessed>      PaymentProcessed      { get; private set; } = null!;
    public Event<PaymentFailed>         PaymentFailed         { get; private set; } = null!;
    public Event<StockReserved>         StockReserved         { get; private set; } = null!;
    public Event<StockReservationFailed> StockReservationFailed { get; private set; } = null!;
    public Event<OrderCancelled>        OrderCancelled        { get; private set; } = null!;

    // Scheduled events (timeouts)
    public Schedule<OrderSagaState, PaymentTimeout>   PaymentSchedule   { get; private set; } = null!;
    public Schedule<OrderSagaState, InventoryTimeout> InventorySchedule { get; private set; } = null!;

    public OrderSaga()
    {
        InstanceState(x => x.CurrentState);

        // Correlate messages by OrderId
        Event(() => OrderPlaced,  x => x.CorrelateById(ctx => ctx.Message.CorrelationId));
        Event(() => PaymentProcessed, x => x.CorrelateById(ctx => ctx.Message.CorrelationId));
        Event(() => PaymentFailed, x => x.CorrelateById(ctx => ctx.Message.CorrelationId));
        Event(() => StockReserved, x => x.CorrelateById(ctx => ctx.Message.CorrelationId));
        Event(() => StockReservationFailed, x => x.CorrelateById(ctx => ctx.Message.CorrelationId));

        // Timeout schedules
        Schedule(() => PaymentSchedule,   x => x.PaymentTimeoutToken,   s => s.Delay = TimeSpan.FromMinutes(5));
        Schedule(() => InventorySchedule, x => x.InventoryTimeoutToken,  s => s.Delay = TimeSpan.FromMinutes(2));

        // Initial state: order placed → request payment
        Initially(
            When(OrderPlaced)
                .Then(ctx =>
                {
                    ctx.Saga.OrderId    = ctx.Message.OrderId;
                    ctx.Saga.CustomerId = ctx.Message.CustomerId;
                    ctx.Saga.OrderTotal = ctx.Message.Total;
                })
                .Publish(ctx => new ProcessPayment(ctx.Saga.CorrelationId, ctx.Saga.CustomerId, ctx.Saga.OrderTotal))
                .Schedule(PaymentSchedule, ctx => new PaymentTimeout(ctx.Saga.CorrelationId))
                .TransitionTo(AwaitingPayment));

        // Payment succeeded → request inventory
        During(AwaitingPayment,
            When(PaymentProcessed)
                .Then(ctx =>
                {
                    ctx.Saga.PaymentRef = ctx.Message.PaymentReference;
                })
                .Unschedule(PaymentSchedule)
                .Publish(ctx => new ReserveStock(ctx.Saga.CorrelationId, ctx.Saga.OrderId))
                .Schedule(InventorySchedule, ctx => new InventoryTimeout(ctx.Saga.CorrelationId))
                .TransitionTo(AwaitingInventory),

            When(PaymentFailed)
                .Then(ctx => ctx.Saga.FailureReason = ctx.Message.Reason)
                .Unschedule(PaymentSchedule)
                .Publish(ctx => new CancelOrder(ctx.Saga.CorrelationId, ctx.Saga.OrderId, "Payment failed"))
                .TransitionTo(Failed),

            When(PaymentSchedule.Received)
                .Publish(ctx => new CancelOrder(ctx.Saga.CorrelationId, ctx.Saga.OrderId, "Payment timeout"))
                .TransitionTo(Failed));

        // Stock reserved → saga complete
        During(AwaitingInventory,
            When(StockReserved)
                .Then(ctx => ctx.Saga.ReservationRef = ctx.Message.ReservationReference)
                .Unschedule(InventorySchedule)
                .Publish(ctx => new OrderFulfilled(ctx.Saga.CorrelationId, ctx.Saga.OrderId))
                .TransitionTo(Completed)
                .Finalize(),

            When(StockReservationFailed)
                .Unschedule(InventorySchedule)
                .Then(ctx => ctx.Saga.FailureReason = ctx.Message.Reason)
                // Compensate: refund the payment already taken
                .Publish(ctx => new RefundPayment(ctx.Saga.CorrelationId, ctx.Saga.PaymentRef!))
                .Publish(ctx => new CancelOrder(ctx.Saga.CorrelationId, ctx.Saga.OrderId, "Stock unavailable"))
                .TransitionTo(Failed));
    }
}

Step 2: Message Contracts

C#
// Commands — instruct a service to do something
public record ProcessPayment(Guid CorrelationId, int CustomerId, decimal Amount);
public record ReserveStock(Guid CorrelationId, int OrderId);
public record RefundPayment(Guid CorrelationId, string PaymentReference);
public record CancelOrder(Guid CorrelationId, int OrderId, string Reason);

// Events — announce that something happened
public record OrderPlaced(Guid CorrelationId, int OrderId, int CustomerId, decimal Total);
public record PaymentProcessed(Guid CorrelationId, string PaymentReference);
public record PaymentFailed(Guid CorrelationId, string Reason);
public record StockReserved(Guid CorrelationId, string ReservationReference);
public record StockReservationFailed(Guid CorrelationId, string Reason);
public record OrderFulfilled(Guid CorrelationId, int OrderId);

// Timeout markers
public record PaymentTimeout(Guid CorrelationId) : IScheduledMessage;
public record InventoryTimeout(Guid CorrelationId) : IScheduledMessage;

Step 3: Service Consumer (Choreography side)

C#
// Payment Service — listens for commands, publishes events
public class PaymentConsumer(
    IPaymentGateway gateway,
    IPublishEndpoint bus)
    : IConsumer<ProcessPayment>
{
    public async Task Consume(ConsumeContext<ProcessPayment> context)
    {
        try
        {
            var reference = await gateway.ChargeAsync(
                context.Message.CustomerId,
                context.Message.Amount,
                context.CancellationToken);

            await context.Publish(new PaymentProcessed(
                context.Message.CorrelationId,
                reference));
        }
        catch (PaymentDeclinedException ex)
        {
            await context.Publish(new PaymentFailed(
                context.Message.CorrelationId,
                ex.Message));
        }
    }
}

Step 4: Register MassTransit with Saga Persistence

C#
// Program.cs
builder.Services.AddMassTransit(x =>
{
    // Persist saga state to PostgreSQL
    x.AddSagaStateMachine<OrderSaga, OrderSagaState>()
        .EntityFrameworkRepository(r =>
        {
            r.ConcurrencyMode = ConcurrencyMode.Pessimistic;
            r.AddDbContext<DbContext, SagaDbContext>((sp, opts) =>
                opts.UseNpgsql(connectionString));
        });

    x.AddConsumer<PaymentConsumer>();
    x.AddConsumer<InventoryConsumer>();

    x.UsingRabbitMq((ctx, cfg) =>
    {
        cfg.Host("rabbitmq://localhost");
        cfg.ConfigureEndpoints(ctx);
    });
});

// SagaDbContext
public class SagaDbContext(DbContextOptions<SagaDbContext> opts) : DbContext(opts)
{
    protected override void OnModelCreating(ModelBuilder model)
    {
        model.AddInboxStateEntity();      // MassTransit inbox — exactly-once delivery
        model.AddOutboxMessageEntity();   // MassTransit transactional outbox
        model.AddOutboxStateEntity();

        model.Entity<OrderSagaState>(e =>
        {
            e.HasKey(s => s.CorrelationId);
            e.Property(s => s.CurrentState).HasMaxLength(64);
            e.Property(s => s.PaymentRef).HasMaxLength(128);
            e.Property(s => s.ReservationRef).HasMaxLength(128);
        });
    }
}

Step 5: Starting the Saga

C#
// Trigger the saga from the Order API
[ApiController]
[Route("api/orders")]
public class OrdersController(IBus bus, IOrderRepository orders) : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> PlaceOrder(PlaceOrderRequest request, CancellationToken ct)
    {
        // Create local order record
        var order = await orders.CreateAsync(request, ct);

        // Start the saga by publishing the initiating event
        await bus.Publish(new OrderPlaced(
            CorrelationId: NewId.NextGuid(),   // MassTransit's globally unique ID
            OrderId:    order.Id,
            CustomerId: order.CustomerId,
            Total:      order.Total),
            ct);

        return Accepted(new { orderId = order.Id });
    }
}

Compensating Transactions Pattern

Saga step sequence:
  Step 1: Create Order         Compensation: Cancel Order
  Step 2: Charge Payment       Compensation: Refund Payment
  Step 3: Reserve Stock        Compensation: Release Reservation
  Step 4: Send Confirmation    (no compensation needed — notification)

When Step 3 fails:
  - Run compensation for Step 2: Refund Payment
  - Run compensation for Step 1: Cancel Order
  - Compensations must be idempotent — they may be called more than once

Idempotency rule: given the same input, compensation always produces
the same result and does not error if already applied.

Interview Answer

"The Saga pattern handles distributed transactions across microservices by replacing a single ACID transaction with a sequence of local transactions plus compensating actions. When step N fails, the saga runs compensations for steps N-1 down to 1 in reverse order. Two flavours: choreography (services react to events — loosely coupled but hard to trace) and orchestration (a central state machine sends commands and handles responses — easier to reason about). In .NET, MassTransit's saga state machine implements orchestration: each state transition is explicit, state is persisted in PostgreSQL via EF Core, and timeouts prevent stuck sagas. The state machine uses CorrelationId to link all messages to one saga instance. Compensating transactions must be idempotent — a refund command that arrives twice should not refund twice. MassTransit's transactional outbox and inbox state entities ensure exactly-once delivery on each step. The key failure modes to handle: payment succeeds but inventory fails (refund), timeout on any step (cancel and refund), and message re-delivery (idempotency check)."

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.