Implementing the Saga Pattern With Wolverine

Implementing the Saga Pattern With Wolverine

6 min read··

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!);
});
  • AutoProvision creates RabbitMQ exchanges and queues automatically
  • UseConventionalRouting routes messages to queues based on message type names
  • DisableConventionalLocalRouting forces all messages through RabbitMQ instead of in-process handling
  • PersistMessagesWithPostgresql stores 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:

Saga pattern state diagram showing message flow from broker to consumer to database and processor.

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:

Saga pattern sequence diagram showing message flow from broker to consumer to database and processor.

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:

  • Start methods create and initialize the saga from a triggering event
  • Handle methods process messages and cascade new commands via return values
  • TimeoutMessage schedules delayed compensation without external schedulers
  • MarkCompleted() cleans up the saga state when the workflow is done
  • NotFound handlers 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.


Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 4,900+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,800+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

Become a Better .NET Software Engineer

Join 70,000+ engineers who are improving their skills every Saturday morning.