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.
A wedding planner for your code
Think about a big Indian wedding. There are many small jobs that must happen in order. Book the hall. Order the food. Hire the band. Send the cards. Arrange the cars. No single person does all of this at once. Instead, there is a wedding planner.
The planner keeps a notebook. After the hall is booked, the planner ticks that off and calls the caterer. After the food is confirmed, the planner ticks that off too and calls the band. The planner waits patiently between calls. Some jobs take days. The planner does not forget where things stand, because everything is written in the notebook.
And here is the important part. If the band suddenly cancels, the planner does not panic. The planner undoes the earlier bookings if needed, or finds a backup, so the family is never left half-arranged.
A saga is exactly this wedding planner for your software. It runs a long job in small steps across many services. It keeps a notebook (the saga state). It waits between steps. And if one step fails, it undoes the earlier work so nothing is left broken.
Wolverine is a .NET library that makes writing these planners very easy. Let us learn how, step by step, in plain words.
Why a normal transaction is not enough
Imagine an online food order. Placing an order touches several services:
- Payment service charges the card.
- Inventory service reserves the food items.
- Delivery service assigns a rider.
On one single database, you could wrap all of this in one transaction. Everything succeeds together, or everything rolls back together. Nice and safe.
But in real systems, these are separate services with separate databases, often on separate machines. You cannot put one big transaction across all of them. The network is slow. Services go down. A rider might take ten minutes to accept. You need a way to coordinate these steps that can survive waiting, failure, and restarts.
That is the job a saga does.
What a saga really is
A saga has two simple ideas inside it.
First, state. This is the saga's notebook. It remembers what has happened so far. For an order saga, the state might hold the order id, whether payment is done, and whether stock is reserved.
Second, steps driven by messages. The saga does not run all at once. Each message that arrives moves the workflow forward by one step. A message comes in, the saga updates its notebook, and then it sends out the next command.
Here is the flow of a happy order, where everything works:
Happy path of an order saga
Steps
Start
Order placed, saga created
Pay
Charge the card
Reserve
Hold the food items
Deliver
Assign a rider
Done
Mark saga complete
Two kinds of sagas
People use the word saga for two different styles. It helps to know both.
| Style | How it works | Who is in charge |
|---|---|---|
| Choreography | Each service listens for events and reacts on its own | No central boss; services just react |
| Orchestration | One central saga sends commands and tracks progress | The saga is the boss |
Wolverine's Saga base class is the orchestration style. There is one clear coordinator that holds the state and tells each service what to do next. This is usually easier to follow and easier to debug, because all the workflow logic lives in one place.
Setting up Wolverine
First, add Wolverine to your project. You will also pick a persistence option. For learning, lightweight storage on PostgreSQL is the simplest. Here is a basic host setup.
using Wolverine;
using Wolverine.Postgresql;
var builder = Host.CreateApplicationBuilder(args);
builder.UseWolverine(opts =>
{
// Tell Wolverine to store messages and saga state in PostgreSQL
var connectionString = builder.Configuration.GetConnectionString("orders")!;
opts.PersistMessagesWithPostgresql(connectionString);
// Turn on durable, reliable messaging so steps survive restarts
opts.Policies.UseDurableLocalQueues();
});
using var host = builder.Build();
await host.RunAsync();The key idea is that Wolverine now has a safe place to keep both the in-flight messages and the saga notebooks. If your app restarts in the middle of a workflow, nothing is lost.
Writing your first saga
A Wolverine saga is just a class that inherits from Wolverine.Saga. Let us model the order workflow.
First, the messages. In messaging we separate commands (do this) from events (this happened). Commands are instructions. Events are announcements in the past tense.
// Commands: instructions to a service
public record ChargePayment(Guid OrderId, decimal Amount);
public record ReserveStock(Guid OrderId);
public record AssignRider(Guid OrderId);
// Events: announcements that something already happened
public record OrderPlaced(Guid OrderId, decimal Amount);
public record PaymentCharged(Guid OrderId);
public record StockReserved(Guid OrderId);
public record RiderAssigned(Guid OrderId);Now the saga itself. Look closely at the method names. Wolverine uses conventions, so the names matter.
using Wolverine;
public class OrderSaga : Saga
{
// This is the saga id. Wolverine matches messages to this.
public Guid Id { get; set; }
public bool PaymentDone { get; set; }
public bool StockReserved { get; set; }
// Start is called by the very first message of the workflow.
// The return value is the next command Wolverine should send.
public static (OrderSaga, ChargePayment) Start(OrderPlaced placed)
{
var saga = new OrderSaga { Id = placed.OrderId };
var charge = new ChargePayment(placed.OrderId, placed.Amount);
return (saga, charge);
}
// Handle methods move the workflow forward one step at a time.
public ReserveStock Handle(PaymentCharged charged)
{
PaymentDone = true;
return new ReserveStock(Id);
}
public AssignRider Handle(StockReserved reserved)
{
StockReserved = true;
return new AssignRider(Id);
}
public void Handle(RiderAssigned assigned)
{
// The last step. The workflow is finished.
MarkCompleted();
}
}Three things make this special.
The Start method creates the saga and returns the first command. Wolverine sees this static method and knows that an OrderPlaced message begins a new saga.
Each Handle method takes an event, updates the notebook, and returns the next command. Returning a message is called cascading. Wolverine sends it for you. You never call a bus directly.
MarkCompleted() tells Wolverine to delete the saga state. The wedding is over; the planner can throw away the notebook.
How Wolverine finds the right saga
When a PaymentCharged event arrives, Wolverine must load the correct saga out of thousands. How does it know which one?
It looks at the message for the saga id, in this order:
| Lookup order | What Wolverine checks | Example |
|---|---|---|
| 1 | A property marked with [SagaIdentity] | [SagaIdentity] Guid OrderRef |
| 2 | A property named after the saga type | OrderSagaId |
| 3 | A property simply named Id | Id |
In our example, the events carry OrderId. If we want Wolverine to use that, we can mark it clearly:
public record PaymentCharged([property: SagaIdentity] Guid OrderId);Once Wolverine reads the id, it loads that exact saga from storage, runs the matching Handle method, saves the updated state, and sends any cascaded command. All of this happens inside one safe unit of work.
Adding a timeout
Real workflows get stuck. The rider never accepts. The payment provider hangs. You do not want a saga waiting forever. So you add a timeout.
Wolverine has a special base type called TimeoutMessage. You create your own timeout and say how long to wait. When a saga returns it, Wolverine schedules it for the future without any external scheduler.
using Wolverine;
// This message will be delivered 30 minutes after it is returned.
public record OrderTimeout(Guid OrderId) : TimeoutMessage(30.Minutes());Now we update the saga to start a timer when the order begins, and to react if the timer fires before we finish.
public class OrderSaga : Saga
{
public Guid Id { get; set; }
public bool Completed { get; set; }
public static (OrderSaga, ChargePayment, OrderTimeout) Start(OrderPlaced placed)
{
var saga = new OrderSaga { Id = placed.OrderId };
return (
saga,
new ChargePayment(placed.OrderId, placed.Amount),
new OrderTimeout(placed.OrderId) // start the 30 minute clock
);
}
public void Handle(OrderTimeout timeout)
{
if (Completed)
return; // already finished, ignore the timer
// Still stuck after 30 minutes. Give up cleanly.
CancelEverything();
MarkCompleted();
}
private void CancelEverything()
{
// Send refund and release-stock commands here.
}
}Notice we return three things from Start: the saga, the first command, and the timeout. Wolverine is happy to send several messages at once. The timeout just rides along.
Undoing work: compensation
This is the heart of why sagas exist. We cannot use one big database transaction across services, so we cannot simply roll back. Instead, when a later step fails, we run compensating actions that undo the earlier successful steps.
If payment succeeded but stock is out of stock, we must refund the payment. Refunding is the compensation for charging. Each forward step has a matching undo step.
| Forward step | Compensating step (the undo) |
|---|---|
| Charge payment | Refund payment |
| Reserve stock | Release stock |
| Assign rider | Cancel rider assignment |
Here is how that looks when a stock reservation fails.
public record StockReservationFailed(Guid OrderId);
public record RefundPayment(Guid OrderId);
public class OrderSaga : Saga
{
public Guid Id { get; set; }
public bool PaymentDone { get; set; }
public RefundPayment Handle(StockReservationFailed failed)
{
// Stock could not be reserved. Undo the payment we already took.
if (PaymentDone)
{
return new RefundPayment(Id);
}
MarkCompleted();
return null!;
}
public void Handle(PaymentRefunded refunded)
{
// The undo is finished. The workflow ends here.
MarkCompleted();
}
}The flow below shows the rollback in action.
Compensation when stock runs out
Steps
Pay OK
Card charged
Reserve fails
No stock left
Refund
Money returned
Done
Saga marked complete
Handling messages for a saga that is gone
Sometimes a message arrives for a saga that no longer exists. Maybe it already completed, or a duplicate message came in late. By default this is an error, but you can handle it gracefully with a NotFound method.
public class OrderSaga : Saga
{
public Guid Id { get; set; }
// Called when a PaymentCharged arrives but the saga is gone.
public static void NotFound(PaymentCharged charged, ILogger<OrderSaga> logger)
{
logger.LogInformation(
"Ignoring late PaymentCharged for order {OrderId}; saga already finished",
charged.OrderId);
}
}This keeps your system calm. A late or duplicate message becomes a quiet log line instead of a crash.
Why testing is so easy
This is one of Wolverine's best gifts. Because a saga is just a plain class with state, you can test it with no mocks, no fakes, and no message broker. You set up a state, call a method, and check the state and the returned message.
[Fact]
public void Charging_payment_should_ask_to_reserve_stock()
{
var saga = new OrderSaga { Id = Guid.NewGuid() };
var next = saga.Handle(new PaymentCharged(saga.Id));
Assert.True(saga.PaymentDone);
Assert.IsType<ReserveStock>(next);
}That is a real unit test of business logic, running in milliseconds. No other saga library in the .NET world makes this quite so clean.
Choosing where to store saga state
Wolverine gives three storage choices. Pick based on your project.
| Storage option | Setup effort | Best when |
|---|---|---|
| Lightweight | Almost none; JSON in a table | You want the simplest start |
| Marten | Low; document in PostgreSQL | You already use Marten or want documents |
| EF Core | Medium; flat queryable table | You want to save saga and business data in one transaction |
For most teams starting out, lightweight storage is the friendliest. As your needs grow, you can move to Marten or EF Core without rewriting your saga logic.
A note on the wider .NET ecosystem
You may have heard of other saga tools like MassTransit, NServiceBus, and Rebus. They are all capable. A few honest points to help you choose:
- MassTransit and NServiceBus now use commercial licenses. For paid production use this can mean real cost, so check their current terms.
- MediatR also moved to a commercial license recently. It is an in-process tool and not a saga library, but people often mention it nearby, so it is worth knowing.
- Wolverine is open source and uses convention-based sagas, which means less ceremony and no state machine DSL to memorise.
The right tool depends on your team, budget, and existing stack. Wolverine is an excellent choice when you want low ceremony and easy testing.
Putting the whole picture together
Here is the full life of an order saga, from the first message to the last.
Each box is just a method on your saga class. Wolverine handles loading state, saving state, sending the next message, and scheduling timeouts. You focus on the business rules.
Quick recap
- A saga is a planner that runs a long job in small steps across many services and remembers where it is.
- We use sagas because one big transaction cannot span separate services, so we coordinate with messages instead.
- A Wolverine saga is a plain class that inherits from
Saga, with aStartmethod and severalHandlemethods. - Handle methods cascade the next command by returning it. You never call a bus directly.
MarkCompleted()deletes the saga state when the workflow ends.- Wolverine finds the right saga using
[SagaIdentity], then a{SagaType}Idproperty, thenId. - A
TimeoutMessageschedules future work so stuck sagas do not wait forever. - Compensation undoes earlier steps when a later step fails, since you cannot roll back across services.
- Sagas are very easy to test because they are plain classes with state in and state out.
- Wolverine offers lightweight, Marten, and EF Core storage; remember that MassTransit, NServiceBus, and MediatR are now commercially licensed.
References and further reading
- Sagas — Wolverine Official Docs
- Marten as Saga Storage — Wolverine Docs
- Integration with Sagas (HTTP) — Wolverine Docs
- Low Ceremony Sagas with Wolverine — Jeremy Miller
- Implementing the Saga Pattern With Wolverine — Milan Jovanovic
Related Patterns
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.
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.
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.
Synchronous vs Asynchronous Communication in Microservices (.NET Guide)
A simple, friendly guide to synchronous vs asynchronous communication in microservices, with .NET examples, diagrams, tables, and clear rules on when to use each.
Understanding Microservices: Core Concepts and Benefits for .NET
A beginner-friendly guide to microservices in .NET: what they are, the core ideas behind them, their real benefits and trade-offs, and when to use them.