Skip to content

Wolverine is the Undisputed Champion of Low Ceremony Code

Jeremy Miller24th August 2026

Wolverine has been built from the very beginning around the idea of minimizing code ceremony -- meaning all the repetitive interfaces, base classes, attributes, adapter noise, and framework-mandated goo that your team has to write over and over again just to satisfy your application framework before you get to write any code that actually matters to your business. And even if AI agents are writing most of your code these days, low ceremony still makes your team more productive -- every bit of framework noise is just more tokens for your coding agents to generate, ingest, and get subtly wrong.

The other major messaging frameworks in .NET have recently been moving in the same direction, and honestly, good for them and good for their users. NServiceBus 10.2 shipped convention-based handlers that finally let you write a message handler without implementing IHandleMessages<T>, and MassTransit v9 added conventional consumers through a [Consumer] attribute so you're no longer forced to implement IConsumer<TMessage> for every single message type. Both are genuine, incremental improvements, and I mean that sincerely.

But since the rest of the field is now competing on low ceremony, let's talk about why we think Wolverine is still the undisputed champion in this category and talk about some of the ways Wolverine can do much more to simplify your application code within your message handlers or HTTP endpoints. Wolverine's advantages here don't come from shaving off an interface at a time. Wolverine is different in that Wolverine adapts to your code in the most efficient way it can instead of forcing you to conform to our API shapes.

Credit Where Credit Is Due

Here's the new NServiceBus 10.2 convention-based handler shape:

csharp
[Handler]
public class DebitAccountHandler
{
    public async Task Handle(
        DebitAccount message,
        IMessageHandlerContext context,
        IAccountService accounts,
        CancellationToken cancellationToken)
    {
        // actual business logic
    }
}

No more IHandleMessages<T>, and you can take services from the container directly as method arguments. That's a real improvement. Note though that the [Handler] attribute is mandatory (it drives their source generator based discovery), the IMessageHandlerContext is still riding along, and the method still has to be an async Task-returning method whether or not it does anything asynchronous.

The Critter Stack community will also recommend stepping away from deeply layered architectural approaches and favor condensed vertical slice architecture styles that would probably eliminate constructs like the IAccountService from the NServiceBus example

And the new MassTransit v9 conventional consumer:

csharp
[Consumer]
public class DebitAccountConsumer : IConsumer
{
    public async Task Consume(
        DebitAccount message,
        IPublishEndpoint publishEndpoint,
        CancellationToken cancellationToken)
    {
        // actual business logic
    }
}

Again, genuinely better than implementing IConsumer<TMessage> per message type. But notice that you still need the [Consumer] attribute and the IConsumer marker interface, the method must be named Consume, it must return Task or ValueTask, and it cannot return a value.

Both frameworks moved toward where Wolverine started in its earliest incarnations. So what does the champ look like?

A Handler Is Just a Method

Here's a fully functional Wolverine message handler, with nothing elided:

csharp
public record DebitAccount(Guid AccountId, decimal Amount);

public static class DebitAccountHandler
{
    public static void Handle(DebitAccount command)
    {
        // actual business logic
    }
}

That's it. Take a quick inventory of what's not there:

  • No interfaces of any kind, marker or otherwise, on either the handler or the message type
  • No attributes
  • No base classes
  • No framework-mandated method arguments like an IMessageHandlerContext or ConsumeContext
  • No mandatory Task return value on a method that doesn't do anything asynchronous
  • Not even a mandatory dependency on the Wolverine assembly from your handler code

Wolverine discovers this handler through naming conventions -- public types with names ending in Handler or Consumer, with public Handle() or Consume() methods, where the first argument is assumed to be the incoming message. The handler class can be static (which Wolverine actually prefers as a small optimization) or an instance class if you'd rather use constructor injection. Your business logic has no idea Wolverine exists, which pays off later in testability and in insulating your code from framework churn.

Method Injection, Without the Service Location Tax

Like the newest NServiceBus and MassTransit handler shapes, Wolverine will happily pass IoC services into your handler methods:

csharp
public static class DebitAccountHandler
{
    public static async Task Handle(
        DebitAccount command,
        IDocumentSession session,
        ILogger logger)
    {
        var account = await session.LoadAsync<Account>(command.AccountId);
        account.Balance -= command.Amount;
        session.Store(account);
        logger.LogInformation("Debited {Amount} from {AccountId}", command.Amount, command.AccountId);
    }
}

Superficially that looks like the same feature the other tools just shipped, but what happens at runtime is very different. When another framework "injects" method arguments, it's doing service location against the underlying container for each argument at message handling time -- create a scope, resolve each parameter type through the container's resolution machinery, then push the results into your method.

Wolverine doesn't do that. At bootstrap time, Wolverine generates and compiles code for each message type that inlines the construction of your handler's dependencies based on what it knows about the service registrations. Singletons get pulled in once as constructor arguments to the generated type and held in fields. Scoped or transient services are constructed inline with plain old new -- and disposed deterministically in a finally block or using scope. In the general case there's no scoped container being created per message, no runtime dictionary lookups, no reflection. It's roughly the code you would have written by hand if you had infinite patience, which is why Wolverine can be faster at this than frameworks that treat the IoC container as a runtime service locator.

Low ceremony and mechanically cheaper. That combination is the whole point of Wolverine's architecture.

More of Your Handlers Get to Be Pure Functions

Pure functions are the undisputed champions of testable code

Both of the competitor samples above are stuck being async Task methods no matter what they do. Wolverine has no such requirement. Handlers can be synchronous, and better yet, a large percentage of Wolverine handlers can be pure functions -- take in a message and some state, return a decision -- with all the messy infrastructure concerns pushed out of your code entirely:

csharp
public static class DebitAccountHandler
{
    // Not a Task in sight, and trivial to unit test
    // with no mocks whatsoever
    public static AccountDebited Handle(DebitAccount command, Account account)
    {
        account.Balance -= command.Amount;
        return new AccountDebited(account.Id, command.Amount);
    }
}

The async keyword isn't free. It's visual noise, it's state machine overhead in the compiled code, and in my experience it's a steady source of subtle bugs in the hands of developers who haven't yet been burned by it. A framework that forces every handler to be asynchronous is forcing accidental complexity. Wolverine simply generates different code depending on whether your method is synchronous or asynchronous, so you only take on async when you're actually doing async work.

And as I'm sure you'd guess, Wolverine's internal adapter interface is indeed asynchronous inside its own execution pipeline

Cascading Messages: Return Values Instead of Side Effects

You probably noticed the return value in that last sample. In the other frameworks, publishing a follow-up message means calling into a context object (context.Publish(), publishEndpoint.Publish()) somewhere in the body of your handler -- a side effect buried in imperative code. In Wolverine, a handler's return value is a cascading message that gets published after the original message succeeds:

csharp
public static class PlaceOrderHandler
{
    // Both events are published as messages when
    // the handler succeeds -- and *only* if it succeeds
    public static (OrderPlaced, ReserveInventory) Handle(
        PlaceOrder command,
        IDocumentSession session)
    {
        var order = new Order(command.OrderId, command.Items);
        session.Store(order);

        return (
            new OrderPlaced(order.Id),
            new ReserveInventory(order.Id, command.Items)
        );
    }
}

This does a lot of heavy lifting for very little code:

  • The signature itself tells you what messages this handler can spawn. Handle(PlaceOrder) → (OrderPlaced, ReserveInventory) is documentation you can't forget to update.
  • Unit tests become "call method, assert on return value." No mock verification of IPublishEndpoint.Publish() calls.
  • Cascaded messages don't go out until the original message succeeds, so you get correct outbox semantics without thinking about it.

Once you get used to reasoning about message flow through method signatures, going back to hunting for Publish() calls scattered through handler bodies feels genuinely primitive.

Discovery Is Built In -- and Bendable

Wolverine finds all of these handlers automatically through built-in type scanning with zero registration code. Contrast that with the note in the NServiceBus announcement that their convention-based handlers are not found by assembly scanning and have to be attached through their [Handler] attribute and source generator machinery.

And because people absolutely will insist on their own conventions -- or be coming from a legacy system -- Wolverine's discovery is adaptable rather than take-it-or-leave-it:

csharp
builder.UseWolverine(opts =>
{
    opts.Discovery.CustomizeHandlerDiscovery(x =>
    {
        // Your team likes "Worker" as a suffix instead? Fine.
        x.Includes.WithNameSuffix("Worker");
    });

    // Or mark up types explicitly, or disable
    // conventional discovery entirely and be 100% explicit
    opts.Discovery.IncludeType<SpecialHandler>();
});

Naming conventions when you want them, explicit registration when you don't, and your own conventions when you inevitably decide you know better than me. Everybody wins.

Or Skip the Scanning Entirely: Pre-Generation and AoT

"But assembly scanning is slow at startup!" -- yes, it can be, which is why Wolverine lets you do all of that work ahead of time. With pre-generated types, Wolverine writes its generated handler code to disk at development or build time, then loads those types directly at startup:

csharp
// In your application bootstrapping
opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Static;
bash
# Generate all the code ahead of time from the command line
dotnet run -- codegen write

In this mode the handler discovery itself is baked into the generated code, so there's no runtime assembly scanning, no runtime Roslyn compilation, and much faster cold starts -- which matters plenty for serverless deployments. It's also the key ingredient in Wolverine's Native AoT support, since AoT compiled applications can't do runtime code generation at all. I wrote more about that journey in How the Critter Stack Got to AoT Compliance.

Middleware You Can Actually Read

Here's a Wolverine capability that genuinely has no equivalent in any other framework in this space: you can read the exact code that runs when a message is handled. Every other messaging framework applies middleware through nested runtime pipelines -- behaviors wrapping behaviors wrapping filters, each one a separate object allocation and virtual dispatch, and the only way to know what actually runs is to go spelunking through framework source or a debugger call stack.

Wolverine's middleware is instead woven inline into the generated code around your handler. Take this handler using Wolverine's transactional middleware with Marten (don't worry about the [Entity] attribute or the Update<Account> return value just yet -- both are explained in the next two sections):

csharp
public static class DebitAccountHandler
{
    public static (AccountDebited, Update<Account>) Handle(
        DebitAccount command,
        [Entity] Account account)
    {
        account.Balance -= command.Amount;
        return (new AccountDebited(account.Id, command.Amount), Storage.Update(account));
    }
}

The [Entity] attribute is currently supported in Wolverine for Marten, Polecat, Fisher, EF Core, RavenDb, and CosmosDb

And here's the code Wolverine actually generates for it. This isn't pseudo-code -- it's the verbatim output of dotnet run -- codegen write against Wolverine 6.29.2, straight out of Internal/Generated/WolverineHandlers, with nothing changed but trimming a few blank lines:

csharp
// <auto-generated/>
#pragma warning disable
using Microsoft.Extensions.Logging;
using Wolverine.Marten.Publishing;

namespace Internal.Generated.WolverineHandlers
{
    // START: DebitAccountHandler1378769044
    [global::System.CodeDom.Compiler.GeneratedCode("JasperFx", "1.0.0")]
    public sealed class DebitAccountHandler1378769044 : Wolverine.Runtime.Handlers.MessageHandler
    {
        private readonly Microsoft.Extensions.Logging.ILogger<CodegenSample.DebitAccount> _loggerForMessage;
        private readonly Wolverine.Marten.Publishing.OutboxedSessionFactory _outboxedSessionFactory;

        public DebitAccountHandler1378769044(Microsoft.Extensions.Logging.ILogger<CodegenSample.DebitAccount> loggerForMessage, Wolverine.Marten.Publishing.OutboxedSessionFactory outboxedSessionFactory)
        {
            _loggerForMessage = loggerForMessage;
            _outboxedSessionFactory = outboxedSessionFactory;
        }

        public override async System.Threading.Tasks.Task HandleAsync(Wolverine.Runtime.MessageContext context, System.Threading.CancellationToken cancellation)
        {
            // Building the Marten session
            await using var documentSession = _outboxedSessionFactory.OpenSession(context);
            // The actual message body
            var debitAccount = (CodegenSample.DebitAccount)context.Envelope.Message;

            System.Diagnostics.Activity.Current?.SetTag("message.handler", "CodegenSample.DebitAccountHandler");
            System.Diagnostics.Activity.Current?.SetTag("handler.type", "CodegenSample.DebitAccountHandler");

            // Try to load the existing saga document
            var account = await documentSession.LoadAsync<CodegenSample.Account>(((CodegenSample.DebitAccount)context.Envelope.Message).AccountId, cancellation).ConfigureAwait(false);
            var result_of_Assert1 = Wolverine.Runtime.Handlers.EntityIsNotNullGuard<CodegenSample.Account>.Assert(account, ((Microsoft.Extensions.Logging.ILogger)_loggerForMessage), "account", context.Envelope);
            // Evaluate whether or not the execution should stop based on the HandlerContinuation value
            if (result_of_Assert1 == Wolverine.HandlerContinuation.Stop) return;

            // The actual message execution
            (var outgoing1, var outgoing2) = CodegenSample.DebitAccountHandler.Handle(debitAccount, account);

            // Outgoing, cascaded message
            await context.EnqueueCascadingAsync(outgoing1).ConfigureAwait(false);

            if (outgoing2 != null)
            {
                // Register the document operation with the current session
                documentSession.Update(outgoing2.Entity);
            }

            // Save all pending changes to this Marten session
            await documentSession.SaveChangesAsync(cancellation).ConfigureAwait(false);

            // Have to flush outgoing messages just in case Marten did nothing because of https://github.com/JasperFx/wolverine/issues/536
            await context.FlushOutgoingMessagesAsync().ConfigureAwait(false);
        }
    }
    // END: DebitAccountHandler1378769044
}

There is no runtime "pipeline" here at all. Opening the outbox-enrolled Marten session, loading the Account entity off the command's AccountId, the "stop cleanly if it doesn't exist" guard, calling your one-line pure function, registering the cascading message and the document update, committing the unit of work, flushing the outgoing messages -- it's all just... code, in one flat method, that you can read, step through, and reason about. You even get the OpenTelemetry activity tagging thrown in for free. When something goes sideways in production at 2AM, the difference between "read the one generated method" and "reconstruct a mental model of nine nested behaviors" is not a small thing.

Declarative Persistence with [Entity]

You saw above that the generated code loaded the Account entity before calling the handler. Wolverine can do that declaratively with the [Entity] attribute:

csharp
public record ApproveInvoice(Guid InvoiceId);

public static class ApproveInvoiceHandler
{
    // Wolverine finds the identity on ApproveInvoice.InvoiceId,
    // loads the Invoice from your persistence tooling of choice
    // (Marten, EF Core, RavenDb...), and stops cleanly with a
    // log message if the invoice doesn't exist
    public static (InvoiceApproved, Update<Invoice>) Handle(
        ApproveInvoice command,
        [Entity] Invoice invoice)
    {
        invoice.Approved = true;
        return (new InvoiceApproved(invoice.Id), Storage.Update(invoice));
    }
}

The entity loading, the "not found" handling, and the update side effect are all declarative, and the handler itself is still a synchronous pure-ish function you can unit test with nothing but an object and an assertion. In Wolverine.HTTP endpoints, that same [Entity] usage gets you an automatic 404 for free.

The Aggregate Handler Workflow: First-Class Event Sourcing

Finally, the category where nobody else in this space is even in the game: event sourcing. Wolverine is the only messaging framework in .NET with first-class event sourcing support, via its deep integration with Marten (and now Polecat for SQL Server) in what we call the aggregate handler workflow:

csharp
public record ShipOrder(Guid OrderId);

public static class ShipOrderHandler
{
    [AggregateHandler]
    public static IEnumerable<object> Handle(ShipOrder command, Order order)
    {
        if (order.HasShipped) yield break;

        yield return new OrderShipped(DateTimeOffset.UtcNow);

        if (order.IsFullyPaid)
        {
            yield return new OrderCompleted();
        }
    }
}

If you've read about the Decider pattern, this is that -- (command, currentState) → events -- as an actual supported programming model rather than a conference talk aspiration. Wolverine uses the ShipOrder.OrderId to fetch the event stream, Marten folds the events into the current Order state for you, your pure function decides what new events (if any) result from the command, and Wolverine appends them to the stream with optimistic concurrency protection, all inside one transaction with full outbox support for any cascaded messages. The handler is a synchronous pure function. Try writing the equivalent in any other .NET messaging framework and count the moving parts you have to manage yourself.

Wrapping Up

Again, I want to be fair here: the recent NServiceBus and MassTransit improvements are real, and if you're already invested in those tools, your code is getting better. But "you can now skip the generic interface" is where Wolverine started. The champion's belt comes from everything built on top of that starting point: dependencies inlined by code generation instead of resolved by service location, synchronous and pure-function handlers, message flow expressed through return values, adaptable discovery conventions, pre-generation for fast cold starts and AoT, middleware you can read as flat code, declarative persistence, and first-class event sourcing.

If this post made you curious, here's where to dig deeper:

  • Railway Programming with Wolverine -- how Wolverine lets you handle validation and sad paths without either exceptions or Result<T> wrappers, which I also wrote about in Result Pattern or Exceptions? Wolverine Lets You Say "Neither"
  • Compound handlers -- the Before() / Load() / Validate() conventions for composing multi-step handlers out of small, individually testable methods
  • Transactional middleware -- the "auto transaction" support you saw in the generated code above
  • Side effects -- returning declarative side effect values like the Storage.Update() usage you saw above, so your handlers can stay synchronous pure functions while Wolverine carries out the actual infrastructure work
  • A-Frame Architecture -- Jim Shore's name for the code structure Wolverine has been nudging you toward this whole post: infrastructure at the edges, pure business logic in the middle, and a thin (generated!) controller stitching them together

And if your team is weighing messaging frameworks, modernizing a .NET system, or just wants to get the most out of the Critter Stack, that's literally what JasperFX Software does. We offer consulting and architectural services as well as official support plans for Marten, Wolverine, and the rest of the Critter Stack -- reach out any time.

RSS Feed · All Rights Reserved.