C# – MassTransit: Saga Pattern

By | 26/08/2026

In this post, we will see what the Saga Pattern is, when we should use it, and how we can implement it in .NET using MassTransit. For this post, we will reuse the same setup we have used in my previuos post: C# – MassTransit.
When building distributed systems, sooner or later we face this problem: a single business operation spans multiple services. Think about an order: we need to process the payment, reserve the stock, and confirm the order. In a monolith with a single database, we would wrap everything in a transaction. In a distributed system, we can’t: each service has its own database, and distributed transactions (two-phase commit) are slow, fragile, and often simply not supported by our infrastructure.
For these scenarios, the Saga Pattern provides a proven solution.
But, what is Saga Pattern?
The Saga Pattern breaks a long-running business process into a series of smaller, local transactions. Each local transaction updates data within a single service and publishes a message or event that triggers the next transaction in the saga. If a step fails, the saga doesn’t roll back (there is nothing like a global rollback in a distributed system). Instead, it executes compensating transactions: explicit actions that semantically undo the previous steps. For example, if the payment succeeded but the stock reservation failed, the compensation is a refund.

There are two main ways to coordinate a saga:
Orchestration: a central component (the orchestrator, or “state machine”) explicitly defines the workflow: which step comes first, what happens on success, what happens on failure. The process is visible, testable, and lives in one place.
Choreography: there is no central coordinator. Each service listens to events and decides what to do next. It’s simple for small workflows, but the overall process becomes invisible: nobody “owns” the flow, and understanding what happens when something fails means reading the code of every service involved.

MassTransit implements the orchestration approach with state machine sagas: a class where we declare states, events, and transitions, while MassTransit takes care of correlation, persistence, and message routing. This is what we will build in this post.


When should we use the Sega Pattern?
The decision to introduce a saga should be driven by the shape of our business process, not by trends. Consider the Saga Pattern when a single business operation spans multiple services (or multiple steps that can fail independently) and we need the system to end up in a consistent state, whatever happens in the middle.


When shouldn’t we use the Sega Pattern?
If our operation lives inside a single service with a single database, a normal local transaction is simpler, faster, and fully atomic. A saga adds states, events, and compensations: we should pay that price only when the process is genuinely distributed.


OUR EXAMPLE
We will implement a simple order saga with three steps:

  1. An order is submitted (via API).
  2. The saga asks the payment service to process the payment.
  3. If the payment succeeds, the saga asks the warehouse service to reserve the stock.
  4. If the stock reservation succeeds, the order is Completed. If it fails, the saga publishes a compensation (refund the payment) and the order ends as Failed.

For simplicity, everything runs in the same application (in a real system, payment and warehouse would be separate services) and we use the in-memory saga repository (in production, we would persist the saga state with Entity Framework Core, MongoDB, etc.). To make the flow easy to test, our fake services use the order total to decide the outcome:

  • Total > 1000 → the payment fails → order Failed (nothing to compensate).
  • Total between 500 and 1000 → the payment succeeds, but the stock reservation fails → refund is published (compensation) → order Failed.
  • Total ≤ 500 → everything succeeds → order Completed.

We will use the same RabbitMQ Docker image from the previous post, so if the container is still running, we are ready to go. Otherwise:

docker-compose up -d


We start creating a .net project (minima API) where we add two libraries:

dotnet add package MassTransit
dotnet add package MassTransit.RabbitMQ

Then, we create the Message Contracts:

namespace Saga_Pattern;

// Event that starts the saga
public record OrderSubmitted(Guid OrderId, decimal Total);

// Commands sent by the saga to the "services"
public record ProcessPayment(Guid OrderId, decimal Total);
public record ReserveStock(Guid OrderId, decimal Total);

// Compensation command
public record RefundPayment(Guid OrderId, decimal Total);

// Events published by the "services" back to the saga
public record PaymentProcessed(Guid OrderId);
public record PaymentFailed(Guid OrderId, string Reason);
public record StockReserved(Guid OrderId);
public record StockReservationFailed(Guid OrderId, string Reason);

Now, we add the Saga State that is the data MassTransit persists between messages.
Every order gets its own instance, correlated by the OrderId.

using MassTransit;

public class OrderState : SagaStateMachineInstance
{
    // CorrelationId is mandatory: it links all messages of the same order
    // to the same saga instance. In our case, it will be the OrderId.
    public Guid CorrelationId { get; set; }

    // MassTransit stores the current state here ("AwaitingPayment", "Completed", etc.)
    public string CurrentState { get; set; } = string.Empty;

    // Business data we want to keep during the workflow
    public decimal Total { get; set; }
}

Then, we define the orchestrator: the entire workflow, including the compensation, is defined in one place:

using MassTransit;
using Saga_Pattern;

public class OrderStateMachine : MassTransitStateMachine<OrderState>
{
    // States
    public State AwaitingPayment { get; private set; } = null!;
    public State AwaitingStock { get; private set; } = null!;
    public State Completed { get; private set; } = null!;
    public State Failed { get; private set; } = null!;

    // Events
    public Event<OrderSubmitted> OrderSubmitted { 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 OrderStateMachine(ILogger<OrderStateMachine> logger)
    {
        // Tell MassTransit which property stores the current state
        InstanceState(x => x.CurrentState);

        // Correlate every event to the saga instance using the OrderId
        Event(() => OrderSubmitted, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => PaymentProcessed, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => PaymentFailed, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => StockReserved, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => StockReservationFailed, x => x.CorrelateById(m => m.Message.OrderId));

        // STEP 1: an order is submitted -> save data, ask for payment
        Initially(
            When(OrderSubmitted)
                .Then(ctx => ctx.Saga.Total = ctx.Message.Total)
                .Publish(ctx => new ProcessPayment(ctx.Saga.CorrelationId, ctx.Saga.Total))
                .TransitionTo(AwaitingPayment));

        // STEP 2: waiting for the payment result
        During(AwaitingPayment,
            When(PaymentProcessed)
                .Publish(ctx => new ReserveStock(ctx.Saga.CorrelationId, ctx.Saga.Total))
                .TransitionTo(AwaitingStock),
            When(PaymentFailed)
                .Then(ctx => logger.LogWarning("Order {OrderId} FAILED: {Reason}",
                    ctx.Saga.CorrelationId, ctx.Message.Reason))
                .TransitionTo(Failed));

        // STEP 3: waiting for the stock result
        During(AwaitingStock,
            When(StockReserved)
                .Then(ctx => logger.LogInformation("Order {OrderId} COMPLETED", ctx.Saga.CorrelationId))
                .TransitionTo(Completed),
            When(StockReservationFailed)
                .Then(ctx => logger.LogWarning("Order {OrderId} FAILED: {Reason}. Refunding payment (COMPENSATION)",
                    ctx.Saga.CorrelationId, ctx.Message.Reason))
                // COMPENSATION: the payment was already taken, so we must undo it
                .Publish(ctx => new RefundPayment(ctx.Saga.CorrelationId, ctx.Saga.Total))
                .TransitionTo(Failed));
    }
}

Then, we define the ‘Consumers services’ used to simulate the payment service and the warehouse service. They receive a command from the saga, do their local work, and publish the result event back:

using MassTransit;
using Saga_Pattern;

// Simulates the payment service
public class ProcessPaymentConsumer(ILogger<ProcessPaymentConsumer> logger) : IConsumer<ProcessPayment>
{
    public async Task Consume(ConsumeContext<ProcessPayment> context)
    {
        logger.LogInformation("Processing payment for Order {OrderId} (Total: {Total})",
            context.Message.OrderId, context.Message.Total);

        if (context.Message.Total > 1000)
        {
            await context.Publish(new PaymentFailed(context.Message.OrderId, "Insufficient funds"));
            return;
        }

        await context.Publish(new PaymentProcessed(context.Message.OrderId));
    }
}

// Simulates the warehouse service
public class ReserveStockConsumer(ILogger<ReserveStockConsumer> logger) : IConsumer<ReserveStock>
{
    public async Task Consume(ConsumeContext<ReserveStock> context)
    {
        logger.LogInformation("Reserving stock for Order {OrderId}", context.Message.OrderId);

        if (context.Message.Total > 500)
        {
            await context.Publish(new StockReservationFailed(context.Message.OrderId, "Item out of stock"));
            return;
        }

        await context.Publish(new StockReserved(context.Message.OrderId));
    }
}

// Compensation handler: undoes the payment
public class RefundPaymentConsumer(ILogger<RefundPaymentConsumer> logger) : IConsumer<RefundPayment>
{
    public Task Consume(ConsumeContext<RefundPayment> context)
    {
        logger.LogInformation("COMPENSATION: refunding {Total} for Order {OrderId}",
            context.Message.Total, context.Message.OrderId);

        return Task.CompletedTask;
    }
}

Finally, we define the Program.cs:

using MassTransit;
using Saga_Pattern;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMassTransit(x =>
{
    // Register the consumers ("services")
    x.AddConsumer<ProcessPaymentConsumer>();
    x.AddConsumer<ReserveStockConsumer>();
    x.AddConsumer<RefundPaymentConsumer>();

    // Register the saga state machine with an in-memory repository.
    // In production: .EntityFrameworkRepository(...) or another persistent store.
    x.AddSagaStateMachine<OrderStateMachine, OrderState>()
        .InMemoryRepository();

    x.UsingRabbitMq((context, cfg) =>
    {
        // Same RabbitMQ instance from the previous post (local Docker)
        cfg.Host("localhost", 5672, "/", h =>
        {
            h.Username("guest");
            h.Password("guest");
        });

        // Let MassTransit create one endpoint (queue) per consumer and for the saga
        cfg.ConfigureEndpoints(context);
    });
});

var app = builder.Build();

// Minimal API endpoint that starts the saga
app.MapPost("/api/orders/submit", async (decimal total, IPublishEndpoint publishEndpoint) =>
{
    var orderId = Guid.NewGuid();

    // Publishing OrderSubmitted creates a new saga instance
    await publishEndpoint.Publish(new OrderSubmitted(orderId, total));

    // The API returns immediately; the saga orchestrates everything asynchronously
    return Results.Accepted(value: new { orderId, total });
});

app.Run();


TESTING THE SAGA
Now, for testing it, we open an API client and we send three requests (Post) at the endpoint ‘api/orders/submit’, one for each scenario:

Scenario 1 – Happy path (total=200) -> POST /api/orders/submit?total=200

Order Completed


Scenario 2 – Compensation (total=800) -> POST /api/orders/submit?total=800

Here, the payment succeeds, but the stock reservation fails.


Scenario 3 – Early failure (total=1500) -> POST /api/orders/submit?total=1500

The payment fails immediately, so there is nothing to compensate: the order simply ends in the Failed state.


This example demonstrates the core of the Saga Pattern with MassTransit: define the workflow as a state machine, let each service execute its local transaction, and handle failures with explicit compensations. From here, the natural next steps are: persisting the saga state with Entity Framework Core (so the workflow survives restarts), adding timeouts with the MassTransit scheduler (what if the payment provider never answers?), and splitting the consumers into real, separate services.
Moreover, exactly as we saw in the previous post, if tomorrow we move from RabbitMQ to Azure Service Bus, the state machine, the contracts, and the consumers don’t change: only the transport configuration does.



Leave a Reply

Your email address will not be published. Required fields are marked *