Skip to main content
SEMastery

Implementing the Saga Pattern with Rebus and RabbitMQ in .NET

Learn the Saga pattern in .NET using Rebus and RabbitMQ with simple real-life examples, diagrams, correlation, compensation, and full C# code you can copy.

13 min readUpdated March 25, 2026

Planning a big Indian wedding

Think about planning a big wedding in India. There is no single person who does everything. Instead, there is one wedding planner who coordinates many vendors. The planner books the caterer, the decorator, the band, and the hall. Each vendor does their own job and confirms back to the planner.

Now imagine the hall booking fails at the last minute. The planner does not just give up and leave the guests hungry. The planner calls back the caterer and decorator to cancel, asks for refunds, and finds a new plan. Each cancellation is an "undo" step.

This is exactly the Saga pattern. The wedding planner is the saga. The vendors are different services. The cancellations are compensating actions. The saga keeps track of who has confirmed and who has not, and it makes sure that either the whole wedding happens, or everything is cleanly undone.

In software, Rebus is our planner's notebook and phone, and RabbitMQ is the postal system that carries messages between vendors. Let us build this step by step.

Why we need sagas at all

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

But modern systems are split into microservices. The order service, payment service, and shipping service each have their own database. You cannot wrap a transaction around all of them. There is no single "rollback" button across many databases and many machines.

So when a customer places an order, you need many steps to happen across services:

  1. Reserve the items in stock.
  2. Charge the customer's card.
  3. Create a shipment.

If step 2 fails (card declined), you must release the stock you reserved in step 1. That undo is a compensating action. The saga remembers the progress and decides what to undo.

A saga coordinates steps across services that each own their own database.

Two ways to run a saga

There are two common styles. It helps to know both before we pick one.

StyleWho is in charge?Good forHard part
OrchestrationOne central saga tells each service what to doClear, easy to debug flowsThe saga can become a bottleneck
ChoreographyNo boss; services react to events on their ownLoose coupling, simple flowsHard to see the whole picture

Rebus sagas use the orchestration style. One saga class holds the whole flow in one place. When you read the saga code, you can see every step and every undo. This makes life much easier when something goes wrong at 2 AM.

Orchestration vs choreography

Orchestration
Choreography
Rebus uses orchestration

Steps

1

Orchestration

Central saga drives every step

2

Choreography

Each service reacts alone

3

Rebus uses orchestration

One class, easy to follow

Two ways services can coordinate a multi-step job.

Meet Rebus and RabbitMQ

Rebus is a free, open-source service bus for .NET. It is released under the MIT license, so there is no cost and no commercial license to buy. This is worth knowing today, because two popular tools — MediatR and MassTransit — have both moved to commercial licensing for many uses. If your team wants a no-fee service bus with saga support, Rebus is a great fit.

RabbitMQ is a message broker. Think of it as a smart post office. Services drop messages into queues, and RabbitMQ delivers them to the right handler. It handles waiting, retries, and routing.

Together they give us:

  • Messages that travel between services.
  • Saga storage so the saga can remember its state between messages.
  • Correlation so Rebus knows which saga instance a message belongs to.
  • Retries and dead-letter queues when things go wrong.

Setting up the project

First, run RabbitMQ. The easiest way is Docker. This command starts RabbitMQ with its web dashboard on port 15672.

docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:management

Now create a .NET worker or web project and add the Rebus packages. We target .NET 10, which is the current LTS release.

dotnet add package Rebus
dotnet add package Rebus.ServiceProvider
dotnet add package Rebus.RabbitMq

Next, register Rebus in Program.cs. We tell it to use RabbitMQ as the transport and to scan our assembly for handlers.

builder.Services.AddRebus((configure, _) => configure
    .Transport(t => t.UseRabbitMq(
        "amqp://guest:guest@localhost", "orders-queue"))
    .Sagas(s => s.StoreInMemory())
    .Routing(r => r.TypeBased()
        .Map<ReserveStock>("orders-queue")
        .Map<ChargeCard>("orders-queue")));
 
builder.Services.AutoRegisterHandlersFromAssemblyOf<OrderSaga>();

We used StoreInMemory saga storage here so the example is easy to run. In real projects you would store saga state in a database such as SQL Server or PostgreSQL, so it survives restarts. Rebus has packages like Rebus.SqlServer for that.

Designing the messages

A saga reacts to messages. We have two kinds:

  • Commands: an instruction to do something. Example: ReserveStock. Sent to one service.
  • Events: a fact that already happened. Example: StockReserved. Published to anyone who cares.

Here are simple message records for our order flow.

// Commands — "please do this"
public record StartOrder(Guid OrderId, string Product, int Quantity);
public record ReserveStock(Guid OrderId, string Product, int Quantity);
public record ChargeCard(Guid OrderId, decimal Amount);
 
// Events — "this already happened"
public record StockReserved(Guid OrderId);
public record StockReservationFailed(Guid OrderId, string Reason);
public record CardCharged(Guid OrderId);
public record CardChargeFailed(Guid OrderId, string Reason);

Notice every message carries an OrderId. This is our correlation key. It is the thread that ties all messages of one order to one saga instance, just like a wedding planner uses one couple's name to group all their bookings.

The saga state

A saga needs to remember things between messages, because messages arrive at different times. The state lives in a small data class. Rebus saves and loads it for you.

public class OrderSagaData : ISagaData
{
    // Required by Rebus
    public Guid Id { get; set; }
    public int Revision { get; set; }
 
    // Our own fields
    public Guid OrderId { get; set; }
    public bool StockReserved { get; set; }
    public bool CardCharged { get; set; }
}

The Id and Revision fields are required by the ISagaData interface. Revision helps Rebus stop two messages from corrupting the same saga at once (this is called optimistic concurrency). Our own fields, like StockReserved, track the progress of the order.

Writing the saga

Now the heart of it. The saga class lists which messages it handles. The special interface IAmInitiatedBy<T> marks the message that starts a brand new saga. Other messages are handled with the normal IHandleMessages<T> interface and must match an existing saga.

public class OrderSaga :
    Saga<OrderSagaData>,
    IAmInitiatedBy<StartOrder>,   // this one starts the saga
    IHandleMessages<StockReserved>,
    IHandleMessages<StockReservationFailed>,
    IHandleMessages<CardCharged>,
    IHandleMessages<CardChargeFailed>
{
    private readonly IBus _bus;
    public OrderSaga(IBus bus) => _bus = bus;
 
    // Tell Rebus how to find the right saga for each message
    protected override void CorrelateMessages(
        ICorrelationConfig<OrderSagaData> config)
    {
        config.Correlate<StartOrder>(m => m.OrderId, d => d.OrderId);
        config.Correlate<StockReserved>(m => m.OrderId, d => d.OrderId);
        config.Correlate<StockReservationFailed>(m => m.OrderId, d => d.OrderId);
        config.Correlate<CardCharged>(m => m.OrderId, d => d.OrderId);
        config.Correlate<CardChargeFailed>(m => m.OrderId, d => d.OrderId);
    }
 
    public async Task Handle(StartOrder message)
    {
        Data.OrderId = message.OrderId;
        // Step 1: ask the stock service to reserve items
        await _bus.Send(new ReserveStock(
            message.OrderId, message.Product, message.Quantity));
    }
 
    public async Task Handle(StockReserved message)
    {
        Data.StockReserved = true;
        // Step 2: ask the payment service to charge the card
        await _bus.Send(new ChargeCard(message.OrderId, 999m));
    }
 
    public async Task Handle(CardCharged message)
    {
        Data.CardCharged = true;
        // All steps done — the saga is finished
        MarkAsComplete();
    }
 
    public async Task Handle(StockReservationFailed message)
    {
        // Nothing was reserved, so just end the saga
        MarkAsComplete();
    }
 
    public async Task Handle(CardChargeFailed message)
    {
        // The card failed AFTER stock was reserved.
        // Run the compensating action: release the stock.
        if (Data.StockReserved)
        {
            await _bus.Send(new ReleaseStock(message.OrderId));
        }
        MarkAsComplete();
    }
}

Three Rebus ideas do the heavy lifting here:

  • CorrelateMessages maps each message's OrderId to the saga's OrderId. Rebus uses this to load the correct saga instance from storage. If no message of an IAmInitiatedBy type matches, a new saga is created.
  • IAmInitiatedBy<StartOrder> says: when a StartOrder arrives and no saga matches, create one.
  • MarkAsComplete() tells Rebus the saga is done, so it can delete the saga data from storage. Forgetting to call this leaves dead saga rows lying around forever.

The happy path, step by step

Let us trace a successful order. Each arrow is a real message traveling through RabbitMQ.

The happy path: every step succeeds and the saga completes.

The saga starts, reserves stock, charges the card, and finishes. The saga data is created at StartOrder, updated at each reply, and deleted when MarkAsComplete runs.

Happy path saga steps

StartOrder
ReserveStock
ChargeCard
Done

Steps

1

StartOrder

Create saga, send ReserveStock

2

ReserveStock

Stock service confirms

3

ChargeCard

Payment confirms

4

Done

MarkAsComplete deletes state

The order saga from start to finish when nothing fails.

When things go wrong: compensation

Now the interesting part. Suppose stock was reserved, but the card is declined. We cannot leave the items locked forever, or other customers cannot buy them. The saga must undo the reservation.

This undo is the compensating action. We add a ReleaseStock command and a handler in the stock service. The saga sends it when CardChargeFailed arrives.

public record ReleaseStock(Guid OrderId);
 
public class StockHandler :
    IHandleMessages<ReserveStock>,
    IHandleMessages<ReleaseStock>
{
    private readonly IBus _bus;
    public StockHandler(IBus bus) => _bus = bus;
 
    public async Task Handle(ReserveStock message)
    {
        bool ok = TryReserve(message.Product, message.Quantity);
        if (ok)
            await _bus.Reply(new StockReserved(message.OrderId));
        else
            await _bus.Reply(
                new StockReservationFailed(message.OrderId, "Out of stock"));
    }
 
    public async Task Handle(ReleaseStock message)
    {
        Release(message.OrderId); // put the items back
    }
}

Here is the failure flow as a picture.

A failure flow: the card is declined, so the saga compensates by releasing stock.

The key idea: a compensating action is not a database rollback. It is a new business action that reverses the effect of an earlier one. Releasing stock, refunding money, and cancelling a shipment are all compensating actions.

A simple state view

It can help to think of the saga as a little machine with states. It moves from one state to the next as messages arrive.

The order saga as a state machine, including the compensation path.

Important things to get right

Sagas are powerful, but a few rules keep them safe. This table is your checklist.

ConcernWhat to doWhy it matters
CorrelationPut the same key (like OrderId) on every messageRebus loads the correct saga instance
IdempotencyMake handlers safe to run twiceRabbitMQ delivers at least once, so duplicates happen
Persistent storageStore saga state in a real database, not memoryState must survive a restart or crash
TimeoutsUse Defer to handle steps that never replyA vendor that goes silent should not hang forever
CompletionAlways call MarkAsComplete() at the endOtherwise saga data piles up forever

Handling steps that never reply

Sometimes a service simply never answers. The payment service might be down. A saga that waits forever is a bug. Rebus lets you schedule a message to your own saga in the future with Defer. If the reply has not arrived by then, you can give up and compensate.

public async Task Handle(StartOrder message)
{
    Data.OrderId = message.OrderId;
    await _bus.Send(new ReserveStock(
        message.OrderId, message.Product, message.Quantity));
 
    // If we hear nothing in 30 seconds, send ourselves a reminder
    await _bus.Defer(TimeSpan.FromSeconds(30),
        new OrderTimedOut(message.OrderId));
}

Then you handle OrderTimedOut in the saga. If the order is not yet complete, you run the compensating actions and finish. This way no saga waits forever.

Why idempotency matters

RabbitMQ promises at-least-once delivery. That means a message can arrive more than once if a consumer crashes after doing work but before acknowledging. So your handlers must be idempotent: running them twice must give the same result as running them once.

For example, when charging a card, do not blindly charge again on a duplicate ChargeCard. Check whether you already have a charge for that OrderId. The saga's own state helps here, because Data.CardCharged already tells you if the work is done. This pairs nicely with the Inbox pattern for extra safety.

Putting it all together

Here is the full picture of our order saga, from the first click to the final result, including both the success and failure branches.

Full order saga

Place order
Reserve stock
Charge card
Ship or compensate

Steps

1

Place order

StartOrder begins the saga

2

Reserve stock

Lock the items

3

Charge card

Take payment, or fail

4

Ship or compensate

Success ships; failure releases stock

Every path the order saga can take.

When you run this, open the RabbitMQ dashboard at http://localhost:15672 (login guest / guest). You can watch the queues fill and drain as messages flow. This makes the whole pattern feel real and easy to debug.

Common mistakes to avoid

  • Forgetting MarkAsComplete(). Your saga storage grows forever with dead rows. Always end the saga.
  • No correlation on a message. Rebus cannot find the saga, so it either fails or starts a wrong new one. Every saga message needs the key.
  • Using in-memory storage in production. A restart wipes every in-flight saga. Use a database store.
  • Treating compensation like a rollback. It is a forward action that undoes effects, and it can fail too. Make compensations retryable and idempotent.
  • Doing too much in one saga. If a saga has twenty steps, split the work. Smaller sagas are easier to test and reason about.

Quick recap

  • A saga runs one big job made of many small steps across services, like a wedding planner coordinating vendors.
  • When a step fails, the saga runs compensating actions to undo earlier steps. This is not a database rollback; it is a new business action.
  • Rebus is a free, open-source .NET service bus. RabbitMQ is the broker that carries the messages between services.
  • A saga has state (ISagaData), is started by IAmInitiatedBy<T>, finds the right instance with CorrelateMessages, and ends with MarkAsComplete().
  • Always use a correlation key like OrderId on every message so Rebus loads the right saga.
  • Make handlers idempotent because RabbitMQ delivers at least once.
  • Use persistent saga storage in production and Defer to handle steps that never reply.
  • Rebus is a strong, no-cost option now that MediatR and MassTransit have moved to commercial licensing for many uses.

References and further reading

Related Patterns