The Saga Pattern in .NET: Managing Distributed Transactions

In a monolith, you wrap multiple database operations in a single transaction. In a distributed system, that's not possible — each service owns its own data. The saga pattern provides a way to manage multi-step business processes across service boundaries, with compensation logic for when things go wrong.

The Two Approaches

Choreography — services react to events. Each service publishes an event when its step completes, and the next service picks it up. No central coordinator.

Orchestration — a central saga orchestrator tells each service what to do and tracks the overall state. The orchestrator knows the full workflow.

Choreography works for simple flows. Orchestration is better when the process has many steps, conditional logic, or you need visibility into the saga's state.

An Order Fulfilment Saga

Consider placing an order:

  1. Order Service creates the order
  2. Payment Service charges the customer
  3. Inventory Service reserves stock
  4. Shipping Service schedules delivery

If payment fails, the order must be cancelled. If inventory reservation fails after payment, the payment must be refunded. Each step needs a compensating action.

Orchestration with MassTransit

MassTransit provides a state machine saga implementation. Define your saga state:

OrderSagaState.cs
public class OrderSagaState : SagaStateMachineInstance
{
    public Guid CorrelationId { get; set; }
    public string CurrentState { get; set; } = string.Empty;
    public Guid OrderId { get; set; }
    public decimal Amount { get; set; }
    public string CustomerEmail { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
}

Define the state machine:

OrderSaga.cs
public class OrderSaga : MassTransitStateMachine<OrderSagaState>
{
    public State PaymentPending { get; private set; } = null!;
    public State InventoryPending { get; private set; } = null!;
    public State Completed { get; private set; } = null!;
    public State Failed { get; private set; } = null!;

    public Event<OrderSubmitted> OrderSubmitted { get; private set; } = null!;
    public Event<PaymentCompleted> PaymentCompleted { get; private set; } = null!;
    public Event<PaymentFailed> PaymentFailed { get; private set; } = null!;
    public Event<InventoryReserved> InventoryReserved { get; private set; } = null!;
    public Event<InventoryReservationFailed> InventoryFailed { get; private set; } = null!;

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

        Event(() => OrderSubmitted, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => PaymentCompleted, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => PaymentFailed, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => InventoryReserved, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => InventoryFailed, x => x.CorrelateById(m => m.Message.OrderId));

        Initially(
            When(OrderSubmitted)
                .Then(ctx =>
                {
                    ctx.Saga.OrderId = ctx.Message.OrderId;
                    ctx.Saga.Amount = ctx.Message.Amount;
                    ctx.Saga.CustomerEmail = ctx.Message.CustomerEmail;
                    ctx.Saga.CreatedAt = DateTime.UtcNow;
                })
                .Publish(ctx => new ProcessPayment(
                    ctx.Saga.OrderId, ctx.Saga.Amount))
                .TransitionTo(PaymentPending));

        During(PaymentPending,
            When(PaymentCompleted)
                .Publish(ctx => new ReserveInventory(ctx.Saga.OrderId))
                .TransitionTo(InventoryPending),
            When(PaymentFailed)
                .Publish(ctx => new CancelOrder(ctx.Saga.OrderId, "Payment failed"))
                .TransitionTo(Failed));

        During(InventoryPending,
            When(InventoryReserved)
                .Publish(ctx => new CompleteOrder(ctx.Saga.OrderId))
                .TransitionTo(Completed),
            When(InventoryFailed)
                .Publish(ctx => new RefundPayment(
                    ctx.Saga.OrderId, ctx.Saga.Amount))
                .Publish(ctx => new CancelOrder(
                    ctx.Saga.OrderId, "Insufficient inventory"))
                .TransitionTo(Failed));
    }
}

The Messages

Example.cs
public record OrderSubmitted(Guid OrderId, decimal Amount, string CustomerEmail);
public record ProcessPayment(Guid OrderId, decimal Amount);
public record PaymentCompleted(Guid OrderId);
public record PaymentFailed(Guid OrderId, string Reason);
public record ReserveInventory(Guid OrderId);
public record InventoryReserved(Guid OrderId);
public record InventoryReservationFailed(Guid OrderId, string Reason);
public record RefundPayment(Guid OrderId, decimal Amount);
public record CancelOrder(Guid OrderId, string Reason);
public record CompleteOrder(Guid OrderId);

Registration

Program.cs
builder.Services.AddMassTransit(x =>
{
    x.AddSagaStateMachine<OrderSaga, OrderSagaState>()
        .EntityFrameworkRepository(r =>
        {
            r.ConcurrencyMode = ConcurrencyMode.Optimistic;
            r.AddDbContext<DbContext, SagaDbContext>();
        });

    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.ConfigureEndpoints(context);
    });
});

Compensation Is Everything

The saga pattern doesn't give you ACID transactions across services. It gives you eventual consistency with explicit compensation. Every forward action must have a compensating action:

Step Forward Action Compensation
Payment Charge customer Refund payment
Inventory Reserve stock Release reservation
Shipping Schedule delivery Cancel shipment

When to Use Sagas

Use sagas when a business process spans multiple services and requires coordination with rollback capability. Don't use sagas for operations that can live within a single service boundary — a local transaction is simpler and more reliable.

Sagas add complexity. You need idempotent handlers, persistent saga state, and thorough testing of failure scenarios. But for genuinely distributed workflows, they're the right tool.