
The Wolverine Best Practices page has been sitting in the documentation since 2023. It was written early in Wolverine's life, it linked out to four blog posts from that year, and it predated most of the tutorials and a lot of the features that make Wolverine what it is today: declarative persistence with [Entity], storage side effects, sticky handlers, partitioned processing, jitter on retries, the idempotency and dead letter queue tutorials, and all of the command line diagnostics. We've just rewritten it from scratch, and this post is the longer, more opinionated version of the same material with the reasoning behind each recommendation.
Before writing the new guide, we did two things. We went back through every post on my blog about organizing Wolverine code, dividing handlers, and resiliency, and we surveyed what the other messaging communities publish as their best practices: NServiceBus, MassTransit, Rebus, Brighter, Axon, the Spring and Kafka crowd, the AWS Builders' Library, the Azure Architecture Center, Google Pub/Sub, Gregor Hohpe, and Udi Dahan. Some of what follows is where Wolverine has strong, differentiating opinions. Some of it is where Wolverine agrees with everybody else, and we think it's worth being explicit about that too.
Let's start with the opinionated part.
Lower Ceremony, Testability, and the A-Frame
Wolverine is purposely designed on the premise that lower code ceremony is valuable for maintainable code. Wolverine is also built to promote testability and to decouple business logic from infrastructure through the "A-Frame Architecture", or "Imperative Shell, Functional Core" if you're coming from a Functional Programming background. While Wolverine can happily be used with the Clean Architecture, that style is not what Wolverine is optimized for, and frankly, the Wolverine core team strongly recommends against any sort of heavily layered Hexagonal Architecture style.
That's the premise behind everything else in the guide, so it's worth spelling out what we mean.
Jim Shore's A-Frame Architecture divides the world into three kinds of responsibilities:
- Logic that makes decisions: how to change the state of the application, or what should happen next
- Infrastructure services like a Marten
IDocumentSession, an EF CoreDbContext, or a gateway to another system - Coordination code sitting on top that delegates to both the infrastructure and the logic, while keeping the two apart

The trick with Wolverine is that you don't write the coordination code. Wolverine generates it around your handler at bootstrap time, or ahead of time in your build. Your handler methods are the logic, and because Wolverine supplies the infrastructure around them, they get to be pure functions far more often than they would with any other framework we know of. Here's the inventory of features that make that work, and every one of them is a deliberate piece of A-Frame support:
- Compound handlers. A
LoadAsync()method fetches data, and theHandle()method receives that data as arguments. - Declarative persistence.
[Entity]loads an entity by the identity on the incoming message, and stops processing if it's missing, so the handler never touches the session. - Storage side effects.
Storage.Update(entity)orStorage.Insert(entity)as a return value describes what to persist. - Cascading messages. A handler returns the messages it wants published instead of calling the bus.
- Side effects. A handler returns a description of any other action against infrastructure, and Wolverine executes it.
- Transactional middleware commits the unit of work and flushes the outbox.
- The aggregate handler workflow for event sourcing, where the handler is a "decider" that returns new events.
Here's what that looks like for a command that records a payment against an invoice:
public record RecordPayment(Guid InvoiceId, decimal Amount, DateTimeOffset PaidAt);
public static class RecordPaymentHandler
{
// Wolverine calls this first by naming convention. Its only job is to
// "set the table" for the main handler by loading the data it needs.
// Note the single round trip for both documents with Marten's Include()
public static async Task<(Invoice, InvoiceCustomer)> LoadAsync(
RecordPayment command,
IDocumentSession session,
CancellationToken cancellation)
{
InvoiceCustomer? customer = null;
var invoice = await session
.Query<Invoice>()
.Include<InvoiceCustomer>(x => x.CustomerId, c => customer = c)
.Where(x => x.Id == command.InvoiceId)
.FirstOrDefaultAsync(token: cancellation);
if (invoice == null) throw new UnknownInvoiceException(command.InvoiceId);
return (invoice, customer!);
}
// The main handler is a synchronous pure function. It receives the loaded
// data as method arguments, makes decisions, and *describes* the outcome
// through its return values. Wolverine does the rest.
public static (IStorageAction<Invoice>, OutgoingMessages) Handle(
RecordPayment command,
Invoice invoice,
InvoiceCustomer customer)
{
var messages = new OutgoingMessages();
if (invoice.IsPaid)
{
// Nothing to persist, but somebody might care
messages.Add(new InvoiceWasAlreadyPaid(invoice.Id));
return (Storage.Nothing<Invoice>(), messages);
}
invoice.IsPaid = true;
invoice.PaidAt = command.PaidAt;
messages.Add(new InvoicePaid(invoice.Id, command.PaidAt));
if (customer.SendPaymentReceipts)
{
messages.Add(new SendPaymentReceipt(invoice.Id, customer.Email));
}
return (Storage.Update(invoice), messages);
}
}All of the business rules are in Handle(), and Handle() is a synchronous function of its inputs. You can test every permutation of those rules without a database, without a host, and without a single mock object:
[Fact]
public void paying_an_open_invoice_updates_it_and_publishes_invoice_paid()
{
// No database, no mocks, no host. Just inputs and outputs.
var invoice = new Invoice { Id = Guid.NewGuid(), Amount = 100 };
var customer = new InvoiceCustomer { Id = Guid.NewGuid(), SendPaymentReceipts = true, Email = "jane@acme.com" };
var command = new RecordPayment(invoice.Id, 100, DateTimeOffset.UtcNow);
var (action, messages) = RecordPaymentHandler.Handle(command, invoice, customer);
action.ShouldBeOfType<Update<Invoice>>().Entity.IsPaid.ShouldBeTrue();
messages.ShouldContain(x => x is InvoicePaid);
messages.ShouldContain(x => x is SendPaymentReceipt);
}Don't bother unit testing LoadAsync() on its own. Cover it with an integration test of the whole handler through a tracked session, or an Alba scenario if it's an HTTP endpoint. The point of the A-Frame is that the two halves need different kinds of tests, and you only write each kind once.
What I want you to notice is what's not there. There's no IInvoiceRepository. There's no IUnitOfWork. There's no IEventPublisher. There's no Result<T> being threaded back up through three layers. Every one of those abstractions exists in a typical Clean Architecture codebase for exactly one reason, which is to get the business logic into a unit test without the database, and the A-Frame gets you there with a method signature.
Vertical Slices, One File at a Time
The second organizing idea is "Vertical Slice Architecture", which despite the name is really just an idea about organizing code by feature rather than by technical layer. Our default recommendation with JasperFx clients is to name a file after the command, event, or HTTP request it handles. A PlaceOrder.cs file contains the PlaceOrder command, its validator as an inner type if there is one, and the PlaceOrderHandler or PlaceOrderEndpoint including any LoadAsync() or Validate() methods it needs. Closely related slices share a namespace. Message contracts shared across modules can live in their own library, and everything else about a slice stays together.
The part that tends to be controversial is that data access that only matters to one slice belongs in that slice, right next to the behavior that uses it. We find it far more common to need to reason about a single system input than it ever is to need to reason about "the data access layer" as a whole, and when the query is in the same file as the decision, you can see the round trips. That's not an aesthetic point. Round trips to the database are the dominant cost in most enterprise systems, and a handler that calls a service that calls a repository that loads one item at a time inside a loop hides that cost from everybody. When you can see it, you can batch it, with Marten's Include() as above, LoadManyAsync(), batched queries, or Wolverine's own batched [Entity] loads.
Which brings us to repositories. We recommend against wrapping Marten or EF Core in repository abstractions. The session is the abstraction. A repository on top of it usually hides the features that reduce round trips while adding nothing but passthrough methods, and the portability it promises almost never materializes because LINQ providers differ and nobody swaps databases under a running system. When complex query logic genuinely is shared between slices, pull it into a reusable query plan or a specification type rather than a repository.
Two corollaries we've learned the hard way:
- If you're using Wolverine.HTTP, don't put Wolverine as a "mediator" underneath
GETendpoints. Use the persistence tooling directly in the query endpoint and lean on integration tests for the query side. - Don't let a shared "services" layer grow up because several slices happen to look alike. Use Wolverine middleware and compound handlers for shared concerns instead.
And one benefit that didn't exist when the original guide was written: an AI coding agent's context window is finite, so the structure of your codebase is effectively part of every prompt. A slice that fits in one file is one an agent can read in full before changing it. A feature smeared across six projects is not. I wrote about this at length in The Codebase Is the Prompt, and the Critter Stack AI Skills exist in part to encode these conventions so that agents working in your codebase follow them as well.
Make the Side Effects Visible
This one comes from a legacy system I worked on that used a Wolverine competitor. A message handler had a huge dependency tree of services that depended on other services, and deep, deep down the call stack a service published a message through the service bus. Nobody could tell what a message did by reading its handler.
So the rule is: publish outgoing messages from the root handler method, and prefer returning them as cascading messages so that the method signature itself tells you what might happen.
public static class ApproveOrderHandler
{
// Preferred: the outgoing message is a return value, so anybody reading
// this handler can see exactly what it might publish
public static OrderApproved? Handle(ApproveOrder command, IApprovalRules rules)
{
// Use services to *decide*, then return the decision
return rules.CanAutoApprove(command)
? new OrderApproved(command.OrderId)
: null; // null means "no cascading message"
}
}Taking IMessageBus as a method argument and calling PublishAsync() is fine too. What's not fine is IService1 calling IService2 calling IMessageBus.PublishAsync(). The rule doesn't mean every follow up step must be a cascading message, only that the side effects of a message are visible from its handler.
Two more things under this heading. If you're using EF Core with the classic "domain events" pattern where entities collect their own events, let Wolverine scrape them from the DbContext inside the transaction rather than injecting a publisher into your entities. I wrote up the three options for that in "Classic" .NET Domain Events with Wolverine and EF Core. And calling IMessageBus.InvokeAsync() from inside another handler to "reuse" logic is guilty until proven innocent. It hides a nested transaction, hides the database round trips, and splits one unit of work into two that can fail independently.
Validate, Then Get Out of the Way
Wolverine has several ways to validate incoming messages and HTTP requests, and the choice comes down to where the rules live. Use a Validate() method on the handler when the rules need services or the current system state, or only apply to that one handler. It keeps the rules beside the happy path, and the main method stays the happy path. Use Data Annotations when your messages already carry the attributes. Use Fluent Validation when you need reusable, composable validators across several handlers, and register them as singletons so Wolverine can inline them into the generated code.
Whichever you pick, don't wrap handler return values in Result<T> types to carry validation failures around. Wolverine stops the processing and reports the problem, with a ProblemDetails response and correct OpenAPI metadata for HTTP. I made the longer argument in Result Pattern or Exceptions for Errors? Wolverine Lets You Say "Neither".
Wolverine.HTTP Over "Wolverine as MediatR"
One more code organization recommendation that's new to the guide. The dominant pattern in .NET web applications for the last several years has been an MVC Core controller action or a Minimal API registration whose only job is to delegate to a mediator, so every HTTP operation is really two things: the endpoint and the handler behind it, plus whatever Result<T> type carries the outcome between them. You can absolutely use Wolverine that way with IMessageBus.InvokeAsync(), and it's a perfectly reasonable first step when you're migrating an existing application. For new code, we think you can build simpler code with "just" a Wolverine.HTTP endpoint, where the endpoint method is the handler and there's only one thing to write, read, and test:
// Two things to maintain, and the controller is pure ceremony
[HttpPost("/api/invoices/{id}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancelInvoice command, IMessageBus bus)
{
var result = await bus.InvokeAsync<Result<InvoiceCancelled>>(command with { InvoiceId = id });
return result.IsSuccess ? Ok(result.Value) : NotFound(result.Error);
}
// One thing. Wolverine answers the 404 for a missing invoice itself,
// and the method is a pure function of its inputs
[WolverinePost("/api/invoices/{id}/cancel")]
public static (InvoiceCancelled, Delete<Invoice>) Cancel(
CancelInvoice command,
[Entity] Invoice invoice)
{
return (new InvoiceCancelled(invoice.Id, command.Reason), Storage.Delete(invoice));
}Everything in the A-Frame section works identically in an HTTP endpoint, and a couple of features exist specifically to replace the plumbing the controller used to carry. A Validate() method on the endpoint acts as a filter: return ProblemDetails and Wolverine stops the request with a 400, with the OpenAPI metadata generated for you. The persistence helpers like [Entity] load what the endpoint needs from the route, query string, or body, and answer the 404 for a missing entity without a line of code in the endpoint. And the cascading messages, storage side effects, and transactional middleware all apply, so an endpoint that changes the database and publishes an event gets the outbox for free.
Which is why you don't need the custom Result<T> or OneOf<> return types that are so common with "Mediator" tooling. Those types exist to carry a failure back up through the controller so it can pick a status code, and with Wolverine.HTTP there's no controller to carry it to. Let the endpoint return its happy path, and let Validate() and [Entity] deal with the rest. I made the longer version of that case in Like Vertical Slice Architecture? Meet Wolverine.HTTP!.
Where Wolverine Agrees With Everybody Else
Now the survey. We read the best practices pages and the guidance posts from the other messaging communities so that the Wolverine guide could say plainly where the whole industry agrees, and point at the Wolverine feature that implements the consensus. Four themes came up everywhere.
Messages are small, immutable contracts
Name commands in the imperative, DoSomething, and events in the past tense, SomethingHappened. A command has one logical owner and is sent to it. An event is a fact and is published to whoever cares. I want to be clear that there's no difference between the two in Wolverine's implementation. A command and an event are both just messages, and the distinction is only their role within your system, but the naming idiom makes that role obvious to everyone reading the code, and it's the idiom the rest of the messaging world uses too.
Messages are data, not behavior, so don't reuse your EF Core entities or Marten documents as messages, because each of those has reasons to change that your subscribers don't share. Keep events thin: identities and the facts that changed, not a copy of the whole entity, because fat events couple every subscriber to the publisher's model. And if a payload really is too big for your broker, Wolverine's claim check support parks it in blob storage and sends a reference instead. Version additively with nullable properties, deploy receivers before senders, and introduce a new versioned type only for a breaking change.
Particular's "Putting your events on a diet" and the MassTransit messages guide say most of this, and so do we now.
Endpoints are the unit of parallelism, ordering, and failure isolation
Wolverine doesn't require you to assign handlers to endpoints. Every message type gets its own local queue by default and conventional routing handles the brokers. But NServiceBus's guide is right that the endpoint is "the fundamental unit of scale", and you'll want to go past the defaults for three reasons:
// Expensive work gets its own queue with capped parallelism so
// it can never starve anything else
opts.ListenToRabbitQueue("expensive")
.ProcessInline()
.ListenerCount(2);
// Cheap, high volume messages get a buffered endpoint with
// plenty of parallelism
opts.ListenToRabbitQueue("notifications")
.BufferedInMemory()
.MaximumParallelMessages(20);
// When ordering matters more than throughput, process one at a
// time on exactly one node in the cluster
opts.ListenToRabbitQueue("ledger")
.ListenWithStrictOrdering();Isolate expensive work so a one second message never waits behind a sixty second batch job. Give high volume work room. And pay for ordering only where you need it, because competing consumers break ordering and that's the normal, healthy state of a messaging system. Sequential() gives you one at a time on a node, ListenWithStrictOrdering() gives you one at a time across the cluster, and partitioned processing keeps messages for the same entity in order while everything else runs in parallel. Every one of those costs throughput, so make the call per endpoint.
And when the real problem is concurrency rather than ordering, meaning two nodes handling messages for the same aggregate, saga, or tenant at the same time, global partitioning routes every message with the same group id to the same node and processes them one at a time there, with no lock anywhere. That's the feature we reach for first with clients who are fighting optimistic concurrency exceptions in a cluster.
When two handlers subscribe to the same event, a failure or a slowdown in one shouldn't block the other. That's what sticky handlers are for:
[StickyHandler("search-index")]
public static class ProductSearchIndexHandler
{
public static void Handle(ProductUpdated message)
{
// update the search index
}
}
[StickyHandler("pricing-cache")]
public static class ProductPricingCacheHandler
{
public static void Handle(ProductUpdated message)
{
// refresh the pricing cache
}
}Or flip MultipleHandlerBehavior.Separated on globally in a modular monolith and get that for every message type.
Divide work along transaction and retry boundaries
The more valuable way to divide handlers has nothing to do with endpoints though, and this is the one I'd most like you to take away. Make each handler cover one transaction and one kind of failure. A handler that updates the database and calls an external web service and sends an email is three different things that fail in three different ways, and one retry policy can't be right for all of them. Retrying the whole handler after the web service times out re-runs the database work, and holds that transaction open for as long as the web service takes to answer.
So split it. The first handler does the database work inside one transaction and cascades a message for each follow up step. Each follow up step gets its own small handler with a retry loop sized for the thing it talks to:
public record PlaceOrder(Guid OrderId, Guid CustomerId, decimal Amount);
public record OrderPlaced(Guid OrderId);
public record NotifyShipper(Guid OrderId);
// Step 1: the database transaction. This commits quickly, and the call to
// the shipping service becomes a cascading message instead of a direct call
// inside this transaction
public static class PlaceOrderHandler
{
public static (Insert<ShippableOrder>, OrderPlaced, NotifyShipper) Handle(PlaceOrder command)
{
var order = new ShippableOrder
{
Id = command.OrderId,
CustomerId = command.CustomerId,
Amount = command.Amount
};
return (Storage.Insert(order), new OrderPlaced(order.Id), new NotifyShipper(order.Id));
}
}
// Step 2: the call to the external web service, in its own handler with its
// own retry loop. A flaky shipping API retries here on its own schedule,
// without re-running the database work and without holding the first
// transaction open while it waits
public static class NotifyShipperHandler
{
public static void Configure(HandlerChain chain)
{
chain.OnException<HttpRequestException>()
.RetryWithCooldown(1.Seconds(), 5.Seconds(), 30.Seconds())
.WithFullJitter()
.Then.MoveToErrorQueue();
}
public static Task Handle(NotifyShipper command, IShippingGateway gateway, CancellationToken token)
{
return gateway.RequestShipmentAsync(command.OrderId, token);
}
}The rules of thumb for where to cut are: one transaction per handler, every call to an external system gets its own handler, and anything that should be able to fail independently is its own handler. The outbox connects the steps, because the cascading messages are persisted in the same transaction as the database work on a durable endpoint, so the follow up steps are guaranteed to happen eventually and never happen for a transaction that rolled back. And the temporal coupling is gone: if the shipping service is down for an hour, orders still get placed, and the NotifyShipper messages wait in the queue until it's back.
The result is a workflow made of small, atomic handlers rather than one long transaction script, which is also exactly the shape that is easiest to retry, to observe, and to unit test. I walked through a bigger version of this in Multi Step Workflows with the Critter Stack.
Prefer purely asynchronous processing
This is the one place in the survey where I'll disagree with the NServiceBus guidance, which treats request/reply over messaging as something to avoid in favor of synchronous calls. Wolverine supports request/reply to a remote endpoint through IMessageBus.InvokeAsync<T>(), and it's a useful tool. But it reintroduces exactly the temporal coupling that asynchronous messaging was supposed to remove: the caller is blocked, the timeout is now your problem, and a slow or missing responder fails the caller. The answer isn't to swap in an HTTP call, which has the same coupling. As much as possible, design workflows as purely asynchronous processing with cascading messages, where each step publishes what happened and the next step reacts, like the shipping example above. Reserve request/reply for sparing, low volume usages where a caller truly can't proceed without the answer.
At-least-once delivery is the contract
This was the single most universal recommendation across every source, from NServiceBus to Pub/Sub to Hohpe. A broker can lose an acknowledgement, a node can crash after handling a message and before acknowledging it, and a retry policy is by definition a repeat delivery. So:
- Prefer naturally idempotent handlers. "Set the thermostat to 68" is safe to repeat. "Raise it by two degrees" is not. Upserts and unique constraints are your friends.
- Dedup by message identity when the operation can't be idempotent. Wolverine's transactional inbox on durable endpoints tracks handled messages by their envelope identity in the same transaction as your business data. That protects you from the same delivery being handled twice. It does not protect you from the same intent being sent twice: an operator clicking a button twice, a client retrying an HTTP call after a timeout, a scheduler republishing an occurrence. For that, the newer logical message deduplication lets you declare a business identity for a message, an order id or an idempotency key from the caller, and Wolverine refuses the second one within a configurable window. I wrote that one up in Logical Message Deduplication with Wolverine.
- Don't rely on ordering. Competing consumers, retries, and recovery after a crash all reorder messages, and handlers that are order-independent are the cheapest kind. When a workflow genuinely must process messages in sequence and they can arrive out of it, the
ResequencerSagabuffers the stragglers and releases them in order. - Put long running handlers on durable endpoints. Every broker puts a clock on an unacknowledged delivery, the SQS visibility timeout, the Azure Service Bus lock duration, and a handler that outlives the clock gets its message redelivered to another node while it's still running. NServiceBus's guidance is to keep the handler under the lock and renew it. Wolverine's durable endpoints sidestep the problem, because the broker is acknowledged as soon as the message is persisted in the inbox, and the handler can take as long as it needs.
- Keep sagas to orchestration. A saga loads its own state, decides, and sends messages. Delegate any I/O to the handlers it commands, keep its transactions short, assume messages arrive out of order (including the one that starts it), and give every saga a way to end, even if that's a scheduled timeout message.
- Test it. Handle the same message twice in a test and assert the second time changed nothing.
One place the communities disagree is on what to call the outbox. NServiceBus markets it as "exactly-once processing". Rebus says exactly-once is simply not possible. MassTransit and the Azure Architecture Center say the outbox does not replace idempotent business logic. We're with MassTransit on that: the outbox removes one class of inconsistency, and idempotent business logic or logical deduplication is still what makes a repeat delivery harmless. You need one of the two.
Sort your errors into categories
Rather than catching exceptions in message handlers, lean on Wolverine's error handling policies. A caught and swallowed exception is the worst outcome of all, because the message is consumed, nothing is retried, and nothing is logged. But "use the error policies" isn't advice until you know what a good policy looks like, and the useful move is to sort the errors you expect into categories, because the right response is different for each:
// 1. Transient or concurrency errors. Retry a few times with a
// short, growing cooldown. Jitter keeps a cluster of nodes
// from retrying in lockstep against a recovering database.
// If that doesn't work, keep the message around for a human.
opts.Policies.OnException<TimeoutException>()
.RetryWithCooldown(50.Milliseconds(), 100.Milliseconds(), 250.Milliseconds())
.WithFullJitter()
.Then.MoveToErrorQueue();
// 2. A dependency is down. Retrying every message one at a time
// just floods your logs and your dead letter queue. Put the
// message back and pause the whole listener instead.
opts.Policies.OnException<SystemIsCompletelyUnusableException>()
.Requeue().AndPauseProcessing(5.Minutes());
// 3. A message that can never succeed. Don't retry, don't pause,
// just get it out of the way. Use MoveToErrorQueue() instead
// if somebody might want to look at it later.
opts.Policies.OnException<InvalidMessageYouWillNeverBeAbleToProcessException>()
.Discard();
// 4. A distressed resource. If a high percentage of *all* messages
// on an endpoint are failing, trip a circuit breaker and give
// the resource a chance to recover
opts.ListenToRabbitQueue("incoming")
.CircuitBreaker(cb =>
{
cb.MinimumThreshold = 10;
cb.TrackingPeriod = 5.Minutes();
cb.FailurePercentageThreshold = 10;
cb.PauseTime = 1.Minutes();
});The cloud providers add some retry hygiene that's worth repeating. Put a timeout on every external call. Retry at one layer, because nested retries multiply (three times three is nine). Don't retry forever, and don't let a long retry schedule hide a failure from the people operating the system. And never retry a deterministic failure: a deserialization error, a validation failure, or a business rule rejection will fail exactly the same way next time.
A corollary that bit us this year, in a handler that was written in a hurry: don't silently return from a handler when a guard clause fails. Nobody will ever find out why the work stopped happening. Throw a specific exception, tell Wolverine to Discard() it, and the discard gets logged and shows up in tracked session test output.
Lastly, your dead letter queue needs an owner. Nothing cleans it up automatically, a growing dead letter queue is the clearest signal you have that something is wrong, and replaying from it is a decision somebody has to make. Put it on a dashboard and alert on it. That's a big part of what CritterWatch is for, with dead letter triage grouped by message and exception type, replay, and alerting. And now that the dead letter queue is exposed through CritterWatch's MCP server, the owner doesn't have to be a person staring at a screen. You can point an LLM agent at it to summarize what's failing, interpret the exception patterns, and propose which messages are safe to replay and which should be discarded, and then let a human make the call.
Running It
The last section of the new guide is the operational advice, and most of it is unchanged in spirit from 2023 with the details brought up to date.
Pre-generate your code. Wolverine depends on runtime generated code rather than IoC container tricks, which is where the performance and the low ceremony come from, but it costs you memory and cold start time if the code is compiled at runtime. Generate it at build time with dotnet run -- codegen write, run with TypeLoadMode.Static in production, and keep the WolverineFx.RuntimeCompilation package registered only in development. Your production image never invokes Roslyn.
Stay away from opaque IoC registrations. Wolverine can inline a service with a simple constructor straight into the generated code, but a Scoped or Transient lambda registration is a black box that has to be resolved through a scoped container every time. The worst case is a lambda that resolves IMessageBus, because the bus it gets is a completely different object from the MessageContext tracking the current message, and the tenant id and everything else will be wrong. If you need the bus, take it as a method argument. If you suspect a service is seeing the wrong context, dotnet run -- codegen preview and look for _serviceScopeFactory.CreateScope() in the generated code.
The original guide was harsher here. It said flatly that passing state into Wolverine through ASP.Net Core's scoped container wasn't going to work, and that was true at the time. The softened version is: we recommend using as little runtime IoC resolution as possible and preferring method injection in handlers, but for the many ASP.Net Core middleware tools that use a scoped IoC container per request to smuggle state between middleware and your HTTP endpoints, custom multi-tenancy middleware and authorization middleware being the usual suspects, Wolverine has you covered now. Since 5.0, Wolverine.HTTP can opt into sharing the HttpContext.RequestServices scope with the rest of the pipeline, either for every service or just for the handful that need it while everything else stays inlined.
Use the diagnostics before the debugger. describe shows you everything Wolverine knows about the application, describe-routing --explain tells you why a message routes the way it does, and codegen preview shows you the code that actually runs. In production, the OpenTelemetry spans carry correlation and causation ids across hops, and queue depth, retry rate, and the dead letter queue on a dashboard will find most problems before your users do.
Shut down gracefully, but don't sweat it in development. Wolverine 3.0 made shutdown a lot more tolerant, and every major release since has kept improving it: listeners drain their in-flight work before stopping, the leader reassigns the listeners and agents of a node that disappears, and the transactional inbox recovers anything that didn't finish. So kill the process while debugging and get on with it. In production, a node that leaves gracefully hands its listeners and agents off immediately while a node that disappears has to be detected as stale first, so make sure your orchestrator sends a TERM signal and waits for the process to drain.
Wrapping Up
The new Best Practices page is live in the Wolverine documentation, every code sample on it is compiled from the Wolverine repository so it can't drift from the real API, and every section links to the tutorial or guide page that goes deeper. If you think we've got something wrong, or you have advice from your own systems that belongs there, the Wolverine Discord and the GitHub issues are open.
And if you'd like help applying any of this to your system, that's a lot of what JasperFx Software does all day.
