Guides/Marten / Wolverine

CQRS in .NET with Wolverine

Ondřej Podhorný

Ondřej Podhorný

2026-09-29 · 18 min read

CQRS in .NET is a split between a command that decides and a query that reads. Wolverine .NET makes that split a handler method, not a mediator interface on every type.

This guide is one order, followed all the way through. ShipOrder is allowed to refuse. When it accepts, it returns OrderShipped. That return is the next message: the warehouse is told, the customer is billed, and the message either stays in this process or leaves for a broker. The MediatR alternative I ship is that pipeline. The side-by-side matrix is on the blog, MediatR and Wolverine, compared.

The read model a query hits is in Marten Event Sourcing in Production. A current document with no stream behind it is Marten as a Document Database. A message that must not outlive the database commit is in Transactional Outbox with Wolverine. The design of that context is Marten, Polecat, and Wolverine architecture. The hands-on version, on your domain, is the Marten and Wolverine workshop.

When the split is worth it

Split the write from the read when they change for different reasons. The command protects an invariant: an order that is already shipped cannot be shipped again. The query serves a screen: open orders for a customer, with the address the screen wants and the aggregate does not. Most of the value is keeping those two from sharing a class.

A command bus in front of CRUD is still CRUD. One writer, one reader, one shape, and a Handle method that inserts a row, is a mapping you now maintain. I introduce the split when a screen wants a join the decision must not know about, or when the write has a rule the screen must not bypass.

Wolverine is the MediatR alternative when the next requirement is a cascade, a retry, or a broker, and the marker interface is only in the way. It is a poor trade when every handler is in-process, the work is a method call, and nobody is asking for messaging. Replacing IRequest so the code looks newer produces a large diff and the same behaviour.

One order

The domain in this guide is shipping an order. The command names the decision. The message it returns names the fact. Two later messages name the work that fact causes.

ShipOrder.cscsharp
public enum OrderStatus { Open, Shipped }

public sealed record Order(Guid Id, OrderStatus Status);

public record ShipOrder(Guid OrderId, DateTimeOffset ShippedAt);

[MessageIdentity("order-shipped")]
public record OrderShipped(Guid OrderId, DateTimeOffset ShippedAt);

public record NotifyWarehouse(Guid OrderId);
public record BillCustomer(Guid OrderId);

[MessageIdentity] pins the name Wolverine puts on the envelope. The default is the full CLR type name. I pin it before the first deploy, because a rename or a namespace move changes that string, and a message already sitting in a queue no longer finds a handler. How the Order itself is stored, as a document or as a stream, is the other guides. This one only decides.

The handler is a function

A command is a type. A handler is a method that accepts that type. Wolverine finds Handle by convention and injects whatever else the method asks for. There is no IRequest, no IRequestHandler, and no constructor whose only job is to store fields.

ShipOrderHandler.cscsharp
public static class ShipOrderHandler
{
    public static async Task<Order> LoadAsync(ShipOrder command, IDocumentSession session)
    {
        var order = await session.LoadAsync<Order>(command.OrderId);
        if (order is null)
            throw new InvalidOperationException($"Order {command.OrderId} does not exist.");

        return order;
    }

    public static OrderShipped Handle(ShipOrder command, Order order)
    {
        if (order.Status != OrderStatus.Open)
            throw new InvalidOperationException(
                $"Order {order.Id} is {order.Status}.");

        return new OrderShipped(order.Id, command.ShippedAt);
    }
}

Wolverine does not take Order from the DI container. LoadAsync on the same class is a compound handler: Wolverine calls it first and passes the result into Handle. The session in that method is Marten. A stream aggregate loaded by [AggregateHandler] is the other way, and it is in the event sourcing guide. Handle itself only decides.

The refusal is the point. An order that is not open does not return OrderShipped, so the warehouse is not told and the customer is not billed. InvalidOperationException is enough here. A custom exception hierarchy does not make the rule clearer.

Return the result when the caller is waiting. Return nothing when the command is fire-and-forget. Take a CancellationToken when you call something that honours it. Do not take one so the signature looks like MediatR.

A query is the same shape with a name that reads. Handle(FindOpenOrders query) returns the list. It does not append events and it does not publish. If a "query" changes state, it is a command with a dishonest name.

The same exception means two different things once a transport is involved. Invoked in-process, it fails the HTTP request and nothing else runs. Sitting on a queue, Wolverine retries it and then moves the envelope to the error queue. Write the handler for the transport it will actually use. That choice is later in this guide.

Cascading messages

A cascading message is a message the handler returns. Wolverine publishes it after the handler succeeds. You do not call IMessageBus.Publish for the ordinary next step. The return value is the next step, and it is visible in the method signature.

Cascade.cscsharp
public static class OrderShippedHandler
{
    public static IEnumerable<object> Handle(OrderShipped shipped)
    {
        yield return new NotifyWarehouse(shipped.OrderId);
        yield return new BillCustomer(shipped.OrderId);
    }
}

Read the pipeline off the types. ShipOrder returns OrderShipped. OrderShipped returns NotifyWarehouse and BillCustomer. Those two stop. A handler that returns a message of a type it also handles will spin: shipped, then shipped, then shipped. I treat a cycle in that graph as a bug I can see in review, not as a surprise I want at 2 a.m.

Publish and return is a double send. If Handle yields NotifyWarehouse and also calls bus.PublishAsync with the same message, the warehouse is told twice. The return is the publish. The bus is for the case the next message is not known until you are inside a branch the signature cannot show. That case is rarer than the habit of calling the bus anyway.

When the cascade has to be in the same database transaction as the decision, it is no longer "publish and hope." That is the outbox, and it has its own guide. This guide only decides that the message exists.

In memory, or a broker

The handler does not change when the transport changes. The configuration does.

An in-memory local queue runs in the Wolverine process. It is the default for work that must happen here and can be retried here. A durable inbox writes the envelope so a process restart does not drop it. Use that for commands the user is not waiting on, when you still cannot afford to lose them.

A broker is a different process. RabbitMQ, Azure Service Bus, SQS, Kafka. The handler is the same method. The message crosses a boundary, so latency, ordering, and duplicates are now properties of the broker, not of a function call.

Transport.cscsharp
opts.LocalQueue("warehouse")
    .UseDurableInbox()
    .MaximumParallelMessages(5);

opts.PublishMessage<NotifyWarehouse>()
    .ToLocalQueue("warehouse");

opts.PublishMessage<BillCustomer>()
    .ToRabbitQueue("billing");

I keep the command the user is waiting on in-process. I put the warehouse notice on a local queue: it is our process, and a second later is fine. I put billing on a broker when another service owns it. Publishing everything to RabbitMQ because "we might scale" adds a broker you now operate, for a call that used to be a method.

MaximumParallelMessages is the cap on how many warehouse notices run at once. The default local queue uses one slot per processor and does not keep order. A handler that calls a slow downstream system will fill the queue if you leave that cap at the size of the machine. Five is a number I set from the downstream limit, not from a hope about throughput.

The failure mode changes with the transport. An in-memory handler throws and the HTTP request fails. A queued message throws, Wolverine retries, then moves it to the error queue. The queued handler must be safe to run twice. The business rule for that second run is the outbox guide's idempotence section. The decision to queue at all is this one.

The day the message changes

A year later OrderShipped needs the time it was shipped, or a carrier code, or a new name. The envelopes already in a durable queue and on the broker still carry the old shape and the old message type string.

Three cases, and they are not the same:

  • A new business fact. OrderCancelled is a new type. It is not version 2 of OrderShipped.
  • A rename of the CLR type, or a move to another namespace, with no [MessageIdentity]. The string on the envelope changes. In-flight messages have no handler. Pin the alias before the first production publish, and leave it there.
  • A shape that replaces the old one, while old envelopes are still out there. That is a new version of the same alias.
OrderShippedV2.cscsharp
[MessageIdentity("order-shipped", Version = 2)]
public record OrderShippedV2(
    Guid OrderId,
    DateTimeOffset ShippedAt,
    string Carrier);

The envelope's message type stays order-shipped. That is what routing reads. Wolverine publishes version 2 with the content type application/vnd.order-shipped.v2+json. Old v1 envelopes are still v1. I keep a handler for v1 until the queues that can contain it are empty. I do not delete the old type in the same deploy that introduces v2. Adding a property with a sensible default is usually a smaller change than a new version. I still pin the alias, so a later rename does not become an outage.

The order document and the event stream have their own versioning. An upcaster on a Marten event is not this. This is the message on the wire.

Where it jams

Four ways this pipeline fails in production. Each one is a choice you can see before the incident.

A cascade cycle. OrderShipped returns NotifyWarehouse, and the warehouse handler returns OrderShipped because someone reused the type. The local queue never drains. CPU climbs. The durable inbox table climbs with it. The fix is a type that means "warehouse was told," not a second OrderShipped.

An unbounded queue. The default local queue will accept work as fast as publishers send it. If NotifyWarehouse calls a carrier API that takes two seconds, and checkout ships fifty orders a second, the channel grows in memory. UseDurableInbox moves that growth into PostgreSQL, which is better than a process death and worse than a cap. MaximumParallelMessages is that cap. I set it from the carrier's limit. I also watch the queue depth. A depth that grows for an hour is a handler that is down or slower than the publishers, not a reason to raise the cap.

A double send. Return the message and also call PublishAsync. The tracked session shows two envelopes. The warehouse picks the order twice. One of those lines has to go. I keep the return.

A handler written for a function call and then moved onto a queue. In-process, a throw rolls the request back and the user sees the error. On the queue, the same throw is a retry, and the second delivery runs the side effect again. Moving BillCustomer to RabbitMQ is a change to the handler's contract, even though the method did not change. If billing the customer twice charges the card twice, the handler is not ready for the broker. Idempotence of that charge is the outbox guide. Knowing that the move created the requirement is this one.

The error queue is the end of the retry, not a log line. I want its depth next to the local queue depth. A growing error queue means a handler is throwing on data that will not succeed if you retry it. Replaying it without reading the exception replays the incident.

The test is the cascade

A unit test of ShipOrderHandler.Handle checks the refusal: an order that is not open throws, an open order returns OrderShipped. That test does not need Wolverine, and it does not need a database.

The test that justifies the framework is the one that follows the return values. InvokeMessageAndWaitAsync does not return until the local cascade has finished, or the timeout fires. If any handler in that activity throws, the session throws.

ShipOrderTests.cscsharp
public async Task shipping_an_open_order_notifies_and_bills()
{
    var orderId = Guid.NewGuid();
    var shippedAt = DateTimeOffset.UtcNow;

    using var host = await Host.CreateDefaultBuilder()
        .UseWolverine(opts =>
        {
            opts.PublishMessage<NotifyWarehouse>()
                .ToLocalQueue("warehouse");
            opts.PublishMessage<BillCustomer>()
                .ToLocalQueue("billing");
        })
        .StartAsync();

    var session = await host.InvokeMessageAndWaitAsync(
        new ShipOrder(orderId, shippedAt));

    session.Sent.SingleMessage<NotifyWarehouse>()
        .OrderId.ShouldBe(orderId);
    session.Sent.SingleMessage<BillCustomer>()
        .OrderId.ShouldBe(orderId);
}

The host in a real fixture is the application host, so LoadAsync has a document session. The snippet shows the routing and the wait. It is not a second, empty host.

IHost is expensive. I build it once for the fixture, not once per fact. IncludeExternalTransports() makes the session wait on a broker. I turn that on only for the test that actually has a broker. The cascade above is local, so the default is the right wait.

Testcontainers belongs in the test that needs PostgreSQL: the outbox row, the document, the stream. A cascade of in-memory messages does not. Pulling a database into this test hides whether the return values are the design, and spends a minute of CI on a fact a tracked session already proved.

What I want on the trace

Wolverine already writes a span for the message. I want three things on it, and I do not add a custom counter per handler to get them.

The message type, the alias from [MessageIdentity] when there is one. The outcome: the handler finished, or the envelope landed in the error queue. The children: NotifyWarehouse and BillCustomer hanging off OrderShipped, so a slow bill is visible as its own span and not as a vague slowness of "shipping."

Queue depth and error-queue depth are the other two numbers. They are gauges over time, not a log line inside Handle. A trace tells me why one order was slow. The depth tells me the pipeline is falling behind. I page on depth that grows, not on a single slow span.

Where MediatR still shows up

Teams already have IRequest through the codebase. Wolverine replaces it at a boundary. New commands go to Wolverine. Old handlers stay until a change touches them. A big-bang rewrite of every IRequestHandler is a large diff and the same behaviour.

If the system is in-process, the handlers are simple, and nobody is asking for a cascade, a retry, or a broker, I leave MediatR in place. The comparison of the two shapes stays on the blog post linked above.