If you have worked in .NET long enough, you have probably wired up MediatR, registered your pipeline behaviours, and moved on. It works. But at some point you need durable messaging, an outbox, scheduled retries, or external transport — and suddenly the lightweight mediator that got you started is nowhere near enough. You bolt on MassTransit or NServiceBus beside it, maintain two sets of conventions, and wonder why your handlers feel like they serve two masters.

Wolverine takes a different approach. Built by Jeremy D. Miller (the mind behind Lamar, Marten, and the original Jasper project), Wolverine is a single library that handles in-process mediation, asynchronous messaging, durable outbox delivery, and HTTP endpoint generation — all through the same handler model. It has quietly grown to over 3.7 million NuGet downloads, ships regular releases (v5.27 landed at the end of March 2026), and targets .NET 8, 9, and 10. If you have not looked at it yet, now is a good time.

What Wolverine actually is

At its core, Wolverine is a message bus. You define messages (plain C# classes or records) and handlers (plain methods), and Wolverine routes one to the other. But unlike MediatR, it does not stop at in-process dispatch. The same handler model extends to RabbitMQ, Azure Service Bus, Amazon SQS, and Kafka transports. A handler you write for local invocation today can be moved to a durable queue tomorrow with a configuration change, not a code change.

The framework integrates into the standard IHost pipeline:

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Host.UseWolverine(opts =>
{
    // Wolverine will scan the application assembly for handlers automatically
});

var app = builder.Build();
app.Run();

That single UseWolverine() call registers the message bus, discovers handlers by convention, and sets up the internal execution pipeline. There are no marker interfaces to implement, no explicit handler registrations to maintain.

Handlers without the ceremony

This is where Wolverine diverges most from MediatR. There are no required interfaces on message types or handler types. You write a public class ending in Handler or Consumer, give it a public method called Handle or Consume, and you are done.

Handlers/CreateOrderHandler.cs
public static class CreateOrderHandler
{
    public static OrderCreated Handle(
        CreateOrder command,
        IDocumentSession session)
    {
        var order = new Order
        {
            CustomerId = command.CustomerId,
            Items = command.Items
        };

        session.Store(order);

        return new OrderCreated(order.Id);
    }
}

Several things are happening here that are worth unpacking:

For comparison, the equivalent MediatR handler requires a class that implements IRequestHandler<CreateOrder, OrderCreated>, a constructor accepting dependencies, and a Handle method with a CancellationToken parameter you probably ignore.

Calling handlers from ASP.NET Core

The IMessageBus service is registered automatically and can be injected into your endpoints:

Endpoints/OrderEndpoints.cs
app.MapPost("/orders", async (CreateOrder command, IMessageBus bus) =>
{
    var created = await bus.InvokeAsync<OrderCreated>(command);
    return Results.Created($"/orders/{created.OrderId}", created);
});

InvokeAsync<T> processes the command synchronously (within the current request) and returns the cascading message. If you do not need the response, InvokeAsync without a type parameter fires the handler and discards any return value.

For fire-and-forget scenarios where you want the message queued locally:

Example.cs
await bus.PublishAsync(new SendOrderConfirmationEmail(order.Id));

This enqueues the message to a local, in-memory queue by default. With durable inbox configuration, it survives process restarts.

Middleware that generates code, not allocations

MediatR pipeline behaviours use a nested delegate pattern. Each behaviour wraps the next, and the runtime walks the chain on every request. It works, but it allocates closures, and every behaviour runs even when it has nothing to do for a particular message type.

Wolverine takes a fundamentally different approach. At startup, it inspects your handlers and middleware, then generates C# source code that weaves them together into a single, flat execution method. No reflection at runtime, no delegate chains, no unnecessary allocations.

Middleware classes follow the same naming conventions as handlers:

Middleware/FluentValidationMiddleware.cs
public class FluentValidationMiddleware<T>
{
    public static async Task<HandlerContinuation> BeforeAsync(
        T message,
        IValidator<T> validator)
    {
        var result = await validator.ValidateAsync(message);

        if (!result.IsValid)
        {
            throw new ValidationException(result.Errors);
        }

        return HandlerContinuation.Continue;
    }
}

Methods named Before, BeforeAsync, Load, or Validate run before the handler. Methods named After, PostProcess, or Finally run after. Wolverine generates the wiring code at configuration time, so the runtime cost is a direct method call — not a chain of delegates.

The key advantage: Wolverine inspects the DI container and only applies middleware when its dependencies are actually registered. If you register a FluentValidation validator for CreateOrder but not for CancelOrder, the generated code for CancelOrder will not include the validation step at all.

Apply middleware globally through policies:

Program.cs
builder.Host.UseWolverine(opts =>
{
    opts.Policies.AddMiddleware<FluentValidationMiddleware<object>>();
});

Or target specific message types:

Example.cs
opts.Policies.ForMessagesOfType<IAccountCommand>()
    .AddMiddleware(typeof(AccountLookupMiddleware));

The durable outbox: messaging that survives crashes

This is where Wolverine leaves mediator-only libraries behind entirely. The transactional outbox pattern ensures that database writes and message publishing either both succeed or both fail. Without it, you risk scenarios where a database transaction commits but the message to RabbitMQ is lost — or vice versa.

Wolverine has built-in outbox support for both Entity Framework Core and Marten (PostgreSQL):

Program.cs
builder.Host.UseWolverine(opts =>
{
    opts.UseEntityFrameworkCoreTransactions();
    opts.PersistMessagesWithSqlServer(connectionString);
    opts.Policies.UseDurableLocalQueues();
});

With this configuration, messages published during a handler are stored in the same database transaction as your business data. A background agent polls for unsent messages and delivers them to their transport. If the process crashes between the commit and delivery, messages are picked up on restart.

The [Transactional] attribute on a handler method tells Wolverine to wrap the entire handler in a database transaction and use the outbox automatically:

Handlers/PlaceOrderHandler.cs
public static class PlaceOrderHandler
{
    [Transactional]
    public static OrderPlaced Handle(
        PlaceOrder command,
        OrderDbContext db)
    {
        var order = new Order { CustomerId = command.CustomerId };
        db.Orders.Add(order);

        // OrderPlaced is published via the outbox — guaranteed delivery
        return new OrderPlaced(order.Id);
    }
}

No manual SaveChangesAsync call. No explicit IMessageBus.PublishAsync. Wolverine's generated code handles the transaction commit and outbox storage together.

Compound handlers: separating data loading from logic

A common complaint about handler patterns is that they mix data access with business logic. Wolverine addresses this with compound handlers — multiple methods on the same class that form a pipeline:

Handlers/ShipOrderHandler.cs
public static class ShipOrderHandler
{
    public static async Task<(Order, Customer)> LoadAsync(
        ShipOrder command,
        IDocumentSession session)
    {
        var order = await session.LoadAsync<Order>(command.OrderId);
        var customer = await session.LoadAsync<Customer>(order!.CustomerId);
        return (order, customer!);
    }

    public static IEnumerable<object> Handle(
        ShipOrder command,
        Order order,
        Customer customer)
    {
        order.Ship(customer.ShippingAddress);

        yield return new OrderShipped(order.Id);
        yield return new NotifyCustomer(customer.Email, order.Id);
    }
}

The LoadAsync method fetches the data. Wolverine feeds its return values into the Handle method as parameters. The Handle method contains pure business logic with no I/O — trivially testable without mocking a database session.

Error handling and retries

Wolverine's error handling is built into the handler pipeline, not bolted on through Polly wrappers or custom pipeline behaviours:

Program.cs
builder.Host.UseWolverine(opts =>
{
    opts.OnException<SqlException>()
        .RetryWithCooldown(50.Milliseconds(), 100.Milliseconds(), 250.Milliseconds());

    opts.OnException<TimeoutException>()
        .Requeue(3);

    opts.OnException<InvalidOperationException>()
        .MoveToErrorQueue();
});

Policies apply globally or per-handler. The retry logic runs inside the generated code, so there is no additional middleware overhead for messages that succeed on the first attempt.

When to choose Wolverine

Wolverine is not the right tool for every situation. If you need a lightweight mediator with zero learning curve and no messaging ambitions, MediatR still does that job well. But consider Wolverine when:

// TIP

If you are evaluating Wolverine as a MediatR replacement, start with DurabilityMode.MediatorOnly — it disables all asynchronous messaging and keeps the footprint minimal while you migrate handlers.

Common pitfalls

Forgetting naming conventions. Wolverine discovers handlers by convention. If your class does not end in Handler or Consumer, or your method is not named Handle or Consume, it will be silently ignored. The [WolverineHandler] attribute can mark classes explicitly if conventions do not suit your naming style.

Mixing constructor injection with static handlers. Static handlers cannot use constructor injection. If you mark a handler static for performance but then need a scoped service, switch to method injection (add the dependency as a method parameter) rather than making the class non-static.

Publishing without durability. By default, PublishAsync sends to an in-memory queue. If the process restarts, those messages are lost. For anything that matters, configure UseDurableLocalQueues() or an external transport.

Cascading messages you did not intend. Every non-void return value from a handler is treated as a cascading message. If your handler returns a DTO for convenience, Wolverine will try to route it. Return void or Task when you do not want cascading behaviour.

Not inspecting generated code. Wolverine can write its generated handler code to disk with opts.CodeGeneration.TypeLoadMode = TypeLoadMode.GenerateCodeDynamically. Reviewing this output helps you understand exactly what the middleware pipeline looks like for each handler — invaluable when debugging unexpected behaviour.

Summary