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.
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:
- Reserve the items in stock.
- Charge the customer's card.
- 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.
Two ways to run a saga
There are two common styles. It helps to know both before we pick one.
| Style | Who is in charge? | Good for | Hard part |
|---|---|---|---|
| Orchestration | One central saga tells each service what to do | Clear, easy to debug flows | The saga can become a bottleneck |
| Choreography | No boss; services react to events on their own | Loose coupling, simple flows | Hard 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
Steps
Orchestration
Central saga drives every step
Choreography
Each service reacts alone
Rebus uses orchestration
One class, easy to follow
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:managementNow 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.RabbitMqNext, 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:
CorrelateMessagesmaps each message'sOrderIdto the saga'sOrderId. Rebus uses this to load the correct saga instance from storage. If no message of anIAmInitiatedBytype matches, a new saga is created.IAmInitiatedBy<StartOrder>says: when aStartOrderarrives 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 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
Steps
StartOrder
Create saga, send ReserveStock
ReserveStock
Stock service confirms
ChargeCard
Payment confirms
Done
MarkAsComplete deletes state
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.
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.
Important things to get right
Sagas are powerful, but a few rules keep them safe. This table is your checklist.
| Concern | What to do | Why it matters |
|---|---|---|
| Correlation | Put the same key (like OrderId) on every message | Rebus loads the correct saga instance |
| Idempotency | Make handlers safe to run twice | RabbitMQ delivers at least once, so duplicates happen |
| Persistent storage | Store saga state in a real database, not memory | State must survive a restart or crash |
| Timeouts | Use Defer to handle steps that never reply | A vendor that goes silent should not hang forever |
| Completion | Always call MarkAsComplete() at the end | Otherwise 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
Steps
Place order
StartOrder begins the saga
Reserve stock
Lock the items
Charge card
Take payment, or fail
Ship or compensate
Success ships; failure releases stock
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 byIAmInitiatedBy<T>, finds the right instance withCorrelateMessages, and ends withMarkAsComplete(). - Always use a correlation key like
OrderIdon every message so Rebus loads the right saga. - Make handlers idempotent because RabbitMQ delivers at least once.
- Use persistent saga storage in production and
Deferto 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
- Rebus Wiki — Coordinating stuff that happens over time
- Implementing the Saga Pattern Using Rebus and RabbitMQ — Code Maze
- Implementing the Saga Pattern with Rebus and RabbitMQ — Milan Jovanović
- Saga distributed transactions pattern — Microsoft Learn
- Orchestrate events with Rebus — Blexin
Related Patterns
Event-Driven Architecture in .NET with RabbitMQ: A Beginner's Guide
Learn event-driven architecture in .NET with RabbitMQ using simple words, real-life examples, exchanges, queues, and clean async C# code you can copy.
The Outbox Pattern in .NET: Never Lose a Message Again
Learn the Outbox Pattern in .NET with simple, real-life examples. Save your data and your messages in one transaction so a broker outage can never lose an event. Includes EF Core code, diagrams, and best practices.
Getting Started With NServiceBus in .NET: A Beginner's Guide
Learn NServiceBus in .NET from scratch: endpoints, commands, events, handlers, retries, and pub-sub. Simple words, real-life examples, code, and diagrams.
Implementing the Saga Pattern With Wolverine in .NET
Learn the saga pattern in .NET with Wolverine: stateful sagas, Start and Handle methods, timeouts, and compensation. Simple words, examples, and diagrams.
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.
Message Ordering in .NET, Solved From First Principles
Learn message ordering in .NET from scratch: why messages arrive out of order, and how partition keys, sessions, and single consumers fix it.