AI workloads are unpredictable, which makes cloud commitments feel like a gamble. Archera insures your commitments against underutilization, so you can push coverage higher without the risk of getting stuck. If usage drops, Archera covers the downside. Commitment Release Guarantee included. Save with Archera.
If you care about clean and maintainable .NET code, Pragmatic .NET Code Rules is worth a look. It's in presale and almost ready, with a focus on real-world practices. Learn more.
Long-running business processes don't fit neatly into a single request.
Think about user onboarding: you register the user, send a verification email, wait for them to verify, and then send a welcome email. Each step depends on the previous one. If the user never verifies, you need a way to handle that.
The Saga pattern breaks this into a sequence of steps, each with its own message and handler. If a step fails or times out, the saga runs compensation logic instead of leaving the system in a broken state.
I've covered sagas with MassTransit and Rebus before. Both work well, but their state machine DSLs come with a fair amount of ceremony. Since MassTransit moved to a commercial license, more teams have been exploring Wolverine as an alternative.
Wolverine takes a different approach - you write a class that extends Saga, define Handle methods for each message type, and cascade new messages from return values.
Wolverine handles routing, persistence, and correlation automatically.
Configuring Wolverine
We need RabbitMQ for message transport and PostgreSQL for durable saga state and messaging.
var connectionString = builder.Configuration.GetConnectionString("user-mgmt");
builder.Host.UseWolverine(options =>
{
options.UseRabbitMqUsingNamedConnection("rmq")
.AutoProvision()
.UseConventionalRouting();
options.Policies.DisableConventionalLocalRouting();
options.PersistMessagesWithPostgresql(connectionString!);
});
AutoProvisioncreates RabbitMQ exchanges and queues automaticallyUseConventionalRoutingroutes messages to queues based on message type namesDisableConventionalLocalRoutingforces all messages through RabbitMQ instead of in-process handlingPersistMessagesWithPostgresqlstores saga state and messages in PostgreSQL. Wolverine uses lightweight saga storage to create a table per saga type, and the durable messaging infrastructure ensures nothing is lost if the process crashes
Wolverine gives you three ways to persist saga state. Lightweight storage (what we're using) serializes saga state as JSON in a per-saga table with zero ORM config. Marten stores sagas as Marten documents with optimistic concurrency and strong-typed IDs. EF Core maps sagas into a flat, queryable table and lets you commit saga state with other data in a single transaction. If you just need saga state management, lightweight storage is the simplest path.
Required packages:
<PackageReference Include="WolverineFx" Version="5.16.2" />
<PackageReference Include="WolverineFx.Postgresql" Version="5.16.2" />
<PackageReference Include="WolverineFx.RabbitMQ" Version="5.16.2" />
The Saga Messages
Before building the saga, let's define all the messages it will work with:
public record SendVerificationEmail(Guid UserId, string Email);
public record VerificationEmailSent(Guid Id);
public record VerifyUserEmail(Guid Id);
public record SendWelcomeEmail(Guid UserId, string Email, string FirstName);
public record WelcomeEmailSent(Guid Id);
public record OnboardingTimedOut(Guid Id) : TimeoutMessage(5.Minutes());
OnboardingTimedOut extends Wolverine's TimeoutMessage, which automatically schedules a delayed delivery.
When the saga starts, Wolverine will deliver this message after 5 minutes.
If the user hasn't verified by then, the saga compensates.
The Saga State Diagram
Here's how the saga transitions between states:
Building the Saga
Here's the complete saga class:
public class UserOnboardingSaga : Saga
{
public Guid Id { get; set; }
public string Email { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public bool IsVerificationEmailSent { get; set; }
public bool IsEmailVerified { get; set; }
public bool IsWelcomeEmailSent { get; set; }
public DateTime StartedAt { get; set; }
// Step 1: Start the saga when UserRegistered is published
public static (
UserOnboardingSaga,
SendVerificationEmail,
OnboardingTimedOut) Start(
UserRegistered @event,
ILogger<UserOnboardingSaga> logger)
{
logger.LogInformation(
"Starting onboarding for user {UserId}", @event.Id);
var saga = new UserOnboardingSaga
{
Id = @event.Id,
Email = @event.Email,
FirstName = @event.FirstName,
LastName = @event.LastName,
};
return (
saga,
new SendVerificationEmail(saga.Id, saga.Email),
new OnboardingTimedOut(saga.Id));
}
// Step 2: Verification email was sent
public void Handle(
VerificationEmailSent @event,
ILogger<UserOnboardingSaga> logger)
{
logger.LogInformation(
"Verification email sent for user {UserId}", Id);
IsVerificationEmailSent = true;
}
// Step 3: User verified their email
public SendWelcomeEmail Handle(
VerifyUserEmail command,
ILogger<UserOnboardingSaga> logger)
{
logger.LogInformation("Email verified for user {UserId}", Id);
IsEmailVerified = true;
return new SendWelcomeEmail(Id, Email, FirstName);
}
// Step 4: Welcome email sent - onboarding complete
public void Handle(
WelcomeEmailSent @event,
ILogger<UserOnboardingSaga> logger)
{
logger.LogInformation("Onboarding complete for user {UserId}", Id);
IsWelcomeEmailSent = true;
MarkCompleted();
}
// Compensation: timeout handler
public void Handle(
OnboardingTimedOut timeout,
ILogger<UserOnboardingSaga> logger)
{
if (IsEmailVerified)
{
logger.LogInformation(
"Timeout ignored - email already verified for user {UserId}",
Id);
return;
}
logger.LogWarning(
"Onboarding timed out for user {UserId} - email not verified",
Id);
MarkCompleted();
}
// NotFound: messages arriving for completed/deleted sagas
public static void NotFound(
VerifyUserEmail command,
ILogger<UserOnboardingSaga> logger)
{
logger.LogWarning(
"Verify email received but saga {Id} no longer exists",
command.Id);
}
public static void NotFound(
OnboardingTimedOut timeout,
ILogger<UserOnboardingSaga> logger)
{
logger.LogInformation(
"Timeout received for already-completed saga {Id}",
timeout.Id);
}
}
A few things worth calling out.
Starting the saga. Start is a static factory that returns a tuple: the saga instance, a SendVerificationEmail command, and a scheduled OnboardingTimedOut message. Wolverine persists the saga and delivers the messages for you.
Handling messages. Wolverine correlates messages to the correct saga instance by looking for a [SagaIdentity] attribute, then {SagaTypeName}Id, then Id. Return void to update state silently, or return a message to cascade a new command.
Warning: Do not call
IMessageBus.InvokeAsync()within a saga handler to execute a command on that same saga. You'll be acting on stale or missing data. Use cascading messages (return values) for subsequent work.
Completing the saga. MarkCompleted() tells Wolverine to delete the saga state from PostgreSQL.
Concurrency. Wolverine applies optimistic concurrency control to saga state by default. If two messages for the same saga arrive at the same time, one succeeds and the other retries automatically.
Timeout and compensation. OnboardingTimedOut fires 5 minutes after the saga started. If the user verified, we ignore it. Otherwise, we compensate and end the saga. This is the key advantage over fire-and-forget workflows.
NotFound handlers. Static NotFound methods handle messages for sagas that no longer exist. You must have one for any message type that could arrive after the saga is deleted. The timeout NotFound handler matters most: in the happy path, the saga completes before the timeout fires.
The Sequence Flow
Here's the happy path where the user verifies before the timeout:
If the user never verifies, the VerifyUserEmail message never arrives.
After 5 minutes, OnboardingTimedOut fires and the saga compensates.
Summary
Wolverine's Saga base class gives you a convention-driven way to implement long-running workflows:
Startmethods create and initialize the saga from a triggering eventHandlemethods process messages and cascade new commands via return valuesTimeoutMessageschedules delayed compensation without external schedulersMarkCompleted()cleans up the saga state when the workflow is doneNotFoundhandlers gracefully handle messages for sagas that no longer exist
The Saga pattern shines when you have multi-step processes with potential failures. Instead of hoping everything goes right, you design for the cases where it doesn't.
What I really like about Wolverine's approach is how little code you need. You skip the state machine DSL and explicit correlation config entirely.
If you want to go deeper on orchestrating distributed workflows and building real-world sagas, check out Modular Monolith Architecture.
Hope this was useful. See you next week.