Skip to main content
SEMastery

Implementing the Saga Pattern with MassTransit in .NET

Learn the Saga pattern in .NET with MassTransit state machines — states, events, correlation, persistence, retries, and compensation, explained in simple, friendly steps.

14 min readUpdated March 18, 2026

Planning a wedding, one step at a time

Imagine your cousin is getting married. There is a lot to arrange: book the hall, order the food from the caterer, and book the band. No single shop does all three. You have to call each one separately.

You decide on an order. First you book the hall. Once that is confirmed, you order the food. Once the caterer says yes, you book the band. You keep a small diary that says where you are: "hall booked, food ordered, waiting for band."

Now think about what happens if the band says no — they are already busy that day. You cannot just stop. You already paid for the hall and the food. So you make some phone calls to undo the earlier bookings: cancel the food order, cancel the hall, get your advance back. Sad, but at least nothing is left half-done.

That diary, those careful steps, and those "undo" phone calls are exactly what the Saga pattern is in software. A saga is a long job made of small steps spread across different services. It remembers where it is, and if a later step fails, it walks backward and cleans up.

MassTransit is a .NET library that makes this much easier. It gives you a tidy way to write the diary, the steps, and the undo actions as a small state machine. Let us learn how, step by step.

Why a normal transaction is not enough

In one database, you can wrap many writes in a single transaction. If anything fails, the database rolls everything back for you. Clean and simple.

But in a microservices world, the order, the payment, the stock count, and the shipping label often live in different services with different databases. You cannot put one transaction around all of them. There is no single "undo everything" button.

Three services, three databases. One big transaction cannot stretch across all of them.

So instead of one big transaction, a saga uses many small local transactions, one per service, glued together with messages. Each small step is safe on its own. The saga is the manager that watches the whole journey and decides what to do next.

The two kinds of saga

There are two common ways to run a saga. It helps to know both, even though we will focus on the second.

StyleWho decides the next stepGood forWatch out for
ChoreographyEach service reacts to events on its ownSmall flows, few stepsHard to see the whole picture; logic is scattered
OrchestrationOne central saga tells each service what to doBigger flows, clear rulesThe orchestrator must be reliable and saved

In choreography, nobody is in charge. Service A publishes an event, Service B hears it and does its bit, then publishes its own event, and so on. It is like a group dance where everyone knows their move. It works, but when something breaks, it is hard to find where you are in the dance.

In orchestration, there is a conductor. One saga holds the diary and sends commands: "now charge the card," "now reserve the stock." This is what a MassTransit state machine saga gives you. The whole process lives in one readable place.

Choreography vs orchestration

Choreography
Orchestration

Steps

1

Choreography

Services react to each other's events; no central brain

2

Orchestration

One saga sends commands and tracks state

Two ways to coordinate the same multi-step job.

The order example we will build

Let us follow a real order. A customer places an order. Our saga must:

  1. Reserve the items in stock.
  2. Take the payment.
  3. Tell shipping to send the box.

If payment fails after stock is reserved, the saga must release the stock again. That release is called a compensation — the matching "undo" for an earlier step.

The happy path moves forward. A failure triggers a compensation that walks back.

Notice how each named box is a state. The saga is always sitting in exactly one state. Messages move it from one state to the next.

Step 1 — Install MassTransit

Add the packages you need. The core library plus a transport (we will use RabbitMQ here) and the Entity Framework persistence package so the saga can save its diary.

// In a terminal, from your project folder:
//   dotnet add package MassTransit
//   dotnet add package MassTransit.RabbitMQ
//   dotnet add package MassTransit.EntityFrameworkCore
 
// A quick note on licensing:
// MassTransit was free and open source for years.
// From v9 it is commercial and source-available.
// There is a free license for local dev and for small companies.
// Check the current pricing page before shipping to production.

Step 2 — Define the messages

Messages are simple records. Some are events (something happened) and some are commands (please do something). MassTransit does not force a special base type — a plain record is fine.

// Events the saga listens for
public record OrderSubmitted(Guid OrderId, decimal Amount);
public record StockReserved(Guid OrderId);
public record PaymentCompleted(Guid OrderId);
public record PaymentFailed(Guid OrderId, string Reason);
 
// Commands the saga sends out to other services
public record ReserveStock(Guid OrderId);
public record TakePayment(Guid OrderId, decimal Amount);
public record ReleaseStock(Guid OrderId);   // the compensation
public record ShipOrder(Guid OrderId);

Keep one idea per message. A small, clear message is easy to version and easy to read in logs.

Step 3 — Define the saga instance (the diary)

The instance is the row we save to the database. It must implement SagaStateMachineInstance. The CorrelationId is the unique key for one running saga. The CurrentState holds which state it is in right now.

public class OrderState : SagaStateMachineInstance
{
    public Guid CorrelationId { get; set; }     // unique per order
    public string CurrentState { get; set; } = null!;
 
    public decimal Amount { get; set; }
    public DateTime SubmittedAt { get; set; }
 
    // Optimistic concurrency: stops two messages from
    // overwriting each other if they arrive at the same time.
    public uint RowVersion { get; set; }
}

The RowVersion is important. Two messages for the same order can arrive almost together. Optimistic concurrency tells the database: "only save if nobody changed this row since I read it." If someone did, MassTransit retries safely.

Step 4 — Define the state machine (the rules)

This is the heart of the saga. You list the states, the events, and what to do when each event arrives in each state. The grammar reads almost like English: Initially, When, Then, TransitionTo, During.

public class OrderStateMachine : MassTransitStateMachine<OrderState>
{
    // States
    public State Submitted { get; private set; } = null!;
    public State Paid { get; private set; } = null!;
    public State Cancelled { get; private set; } = null!;
 
    // Events, each correlated to one order by OrderId
    public Event<OrderSubmitted> Submitted_ { get; private set; } = null!;
    public Event<StockReserved> Reserved { get; private set; } = null!;
    public Event<PaymentCompleted> PaidOk { get; private set; } = null!;
    public Event<PaymentFailed> PayFailed { get; private set; } = null!;
 
    public OrderStateMachine()
    {
        InstanceState(x => x.CurrentState);
 
        Event(() => Submitted_, e => e.CorrelateById(m => m.Message.OrderId));
        Event(() => Reserved, e => e.CorrelateById(m => m.Message.OrderId));
        Event(() => PaidOk, e => e.CorrelateById(m => m.Message.OrderId));
        Event(() => PayFailed, e => e.CorrelateById(m => m.Message.OrderId));
 
        // A new order starts here
        Initially(
            When(Submitted_)
                .Then(ctx =>
                {
                    ctx.Saga.Amount = ctx.Message.Amount;
                    ctx.Saga.SubmittedAt = DateTime.UtcNow;
                })
                .Send(ctx => new ReserveStock(ctx.Message.OrderId))
                .TransitionTo(Submitted));
 
        // While waiting in Submitted state
        During(Submitted,
            When(Reserved)
                .Send(ctx => new TakePayment(ctx.Saga.CorrelationId, ctx.Saga.Amount)),
 
            When(PaidOk)
                .Send(ctx => new ShipOrder(ctx.Saga.CorrelationId))
                .TransitionTo(Paid)
                .Finalize(),
 
            // Payment failed: undo the stock reservation, then cancel
            When(PayFailed)
                .Send(ctx => new ReleaseStock(ctx.Saga.CorrelationId))
                .TransitionTo(Cancelled)
                .Finalize());
    }
}

Read it slowly. Initially means "this is how a brand new saga begins." When(Reserved) means "if the stock-reserved event arrives." Send fires a command to another service. TransitionTo moves the diary to a new state. Finalize marks the saga as done.

The most important line is the PayFailed branch. That is the compensation. When payment fails, we send ReleaseStock to give the items back, then move to Cancelled. The saga undoes its own work.

How correlation actually works

Correlation is how MassTransit knows which saga a message belongs to. Every event uses CorrelateById, which reads the OrderId from the message and matches it to the CorrelationId of a saved saga row.

Correlating a message to a saga

Event
CorrelationId
Load
Run
Save

Steps

1

Event arrives

PaymentCompleted with OrderId

2

CorrelationId

Use OrderId to find the saga

3

Load row

Read OrderState from the database

4

Run behavior

Apply the When/Then for current state

5

Save row

Persist new state and data

From an incoming event to the right saved row.

If no saved row matches and the event is not an "initial" event, MassTransit can ignore it or move it to an error queue. That is good — it stops stray messages from creating ghost sagas.

Step 5 — Persist the saga with Entity Framework

A saga must survive restarts. If your service crashes between payment and shipping, the diary must still be there when it comes back. So we save it to a database.

First a small mapping that tells EF Core how to store the row. Using CorrelationId as a clustered, unique primary key keeps lookups fast and avoids deadlocks under load.

public class OrderStateMap : SagaClassMap<OrderState>
{
    protected override void Configure(
        EntityTypeBuilder<OrderState> entity,
        ModelBuilder model)
    {
        entity.Property(x => x.CurrentState).HasMaxLength(64);
        entity.Property(x => x.Amount);
        entity.Property(x => x.SubmittedAt);
 
        // Optimistic concurrency token
        entity.Property(x => x.RowVersion).IsRowVersion();
    }
}

Then wire it all up at startup:

builder.Services.AddMassTransit(x =>
{
    x.AddSagaStateMachine<OrderStateMachine, OrderState>()
        .EntityFrameworkRepository(r =>
        {
            // Optimistic is the usual choice; the saga logic is quick.
            r.ConcurrencyMode = ConcurrencyMode.Optimistic;
            r.AddDbContext<DbContext, OrderSagaDbContext>((provider, options) =>
            {
                options.UseNpgsql(connectionString);
            });
        });
 
    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.Host("localhost", "/", h =>
        {
            h.Username("guest");
            h.Password("guest");
        });
        cfg.ConfigureEndpoints(context);
    });
});

Now after every event, MassTransit loads the saga row, runs your When/Then logic, and saves the new state. If two messages race, the RowVersion makes the loser retry instead of clobbering the winner.

Choosing a persistence store

You are not stuck with Entity Framework. MassTransit supports several stores. Pick based on what your team already runs.

StoreConcurrency styleNice when
Entity Framework (SQL)Optimistic or pessimisticYou already use SQL Server or PostgreSQL
MongoDBOptimistic (versioned)You prefer documents over tables
RedisOptimisticYou want very fast, simple state
Azure Cosmos DBOptimisticYou run fully on Azure

For most teams, Entity Framework with optimistic concurrency is the safe default. Saga steps are short, so clashes are rare, and optimistic mode avoids holding database locks.

What happens when a step fails

This is where sagas earn their keep. Failures come in two flavours, and you handle them differently.

A transient failure is a temporary hiccup — the network blinked, the database was busy for a second. The right move is to retry. MassTransit can retry a message a few times with a small delay before giving up.

A real failure is when a step truly cannot succeed — the card was declined, the item is sold out. Retrying will not help. The right move is to compensate — run the undo actions for the steps already done, then move the saga to a cancelled state.

Retry transient hiccups; compensate real failures by undoing prior steps.

You add retry rules per endpoint:

cfg.ReceiveEndpoint("order-saga", e =>
{
    // Retry transient errors 3 times, waiting a bit longer each time
    e.UseMessageRetry(r => r.Incremental(
        retryLimit: 3,
        initialInterval: TimeSpan.FromSeconds(1),
        intervalIncrement: TimeSpan.FromSeconds(2)));
 
    e.ConfigureSaga<OrderState>(context);
});

Keep the two ideas separate in your head. Retries handle bad luck. Compensations handle bad news.

Timeouts and stuck sagas

Sometimes a step simply never answers. The payment service is down, so PaymentCompleted never arrives, and the saga waits forever. To avoid this, sagas use scheduled timeouts.

You ask MassTransit to "wake me in 5 minutes." If the expected event has not arrived by then, the timeout fires and you can cancel the order or alert someone. This keeps a saga from getting stuck in a waiting state for days.

A safety timeout

Wait
Timer
Check
Act

Steps

1

Enter wait

Schedule a 5-minute timeout

2

Timer fires

Timeout event is delivered

3

Check state

Did the awaited event arrive?

4

Act

If not, cancel or compensate

If the awaited event never comes, the timeout rescues the saga.

Timeouts need the scheduler turned on (an in-memory scheduler for tests, or a delayed-message exchange / Quartz in production). It is a small bit of setup that saves you from silent, frozen orders.

Keeping consumers idempotent

Messages can arrive more than once. Networks retry; brokers re-deliver. So the services that handle saga commands must be idempotent — handling the same command twice must not charge the card twice.

A common trick is to pair your consumers with the Inbox pattern: record the message id the first time you process it, and skip it if you see the same id again. This pairs nicely with sagas, because a saga sends the same command after a retry.

Putting the whole flow together

Here is the full happy path and the compensation path side by side, so you can see the saga as one picture.

The saga sends commands, waits for events, and either finishes or compensates.

Read the sequence top to bottom. The saga never does the work itself. It only sends commands and reacts to events, updating its diary as it goes. That separation is what makes sagas easy to test and easy to reason about.

Common mistakes to avoid

A few traps catch most beginners. Knowing them up front saves hours.

  • Putting business work inside the saga. The saga should coordinate, not calculate prices or call payment APIs directly. Keep that logic in the services. The saga just sends and waits.
  • Forgetting compensations. Every forward step that changes the world needs a matching undo. If you reserve stock, you must be able to release it.
  • Skipping persistence. An in-memory saga forgets everything on restart. Always persist in production.
  • Ignoring idempotency. Re-delivered messages will charge twice if your consumers are not idempotent.
  • No timeout on waits. A saga waiting for an event that never comes will sit stuck forever.

Quick recap

  • A saga is a long job made of small steps across different services, with a diary that remembers progress and compensations that undo earlier steps when a later one fails.
  • Use a saga only when a process spans several services or databases. For a single database, a normal transaction is simpler and better.
  • Choreography spreads the logic across services; orchestration keeps it in one place. MassTransit state machines give you clean orchestration.
  • A MassTransit saga has states, events, and behaviors written with Initially, When, Then, TransitionTo, and During in a class deriving from MassTransitStateMachine.
  • Correlation links each message to the right saga row using CorrelateById and the CorrelationId.
  • Persist the saga (Entity Framework, MongoDB, Redis, Cosmos) so it survives restarts, and prefer optimistic concurrency.
  • Handle failures two ways: retry transient hiccups, compensate real failures. Add timeouts so sagas never freeze, and keep consumers idempotent.
  • MassTransit is now a commercial, source-available product (free for local dev and small companies) — check the current license before shipping.

References and further reading

Related Patterns