Use Wolverine when you need:
- High-performance in-process command handling (Mediator replacement)
- Durable messaging with Inbox / Outbox pattern
- Seamless transactional consistency between database & messaging
- A single framework for:
- Commands
- Events
- Background processing
- External brokers
- Minimal ceremony compared to MassTransit / NServiceBus
Avoid Wolverine when:
- You only need a simple mediator with no async messaging
- Your team already standardized on MassTransit or NServiceBus
- You need advanced Saga orchestration out of the box
Installation & Setup
Core Package
dotnet add package WolverineFx
Entity Framework Core Integration
dotnet add package WolverineFx.EntityFrameworkCore
Register Wolverine in Startup
builder.Host.UseWolverine(opts =>
{
// configuration here
});
Define Messages & Handlers
Message (Command / Event)
public class ItemCommand
{
public long Id { get; set; }
}
Handler
public class ItemHandler
{
public async Task Handle(ItemCommand command, AppDbContext dbContext)
{
// handle command
}
}
Handler Discovery Conventions
A handler is detected if:
- Class name ends with Handler or Consumer
- OR decorated with [WolverineHandler]
- OR implements IWolverineHandler
Static vs Instance Handlers
public static class StaticHandler
{
public static Task Handle(ItemCommand command) { }
}
- Static handlers are preferred for performance
- Instance handlers allow constructor injection
- Both are supported
Handler Return Values (Chained Messaging)
public class CreateOrderHandler
{
public OrderCreated Handle(CreateOrder command)
{
return new OrderCreated(command.Id);
}
}
- Returned messages are automatically published or sent
- Wolverine will look for handlers for returned message types
- Enables pipeline-style workflows
Transaction & EF Core Behavior
If a handler has DbContext as a parameter:
- Wolverine automatically wraps handler execution in a transaction
- Outgoing messages are stored in Outbox within the same transaction
- If the handler fails:
- DB changes are rolled back
- Outgoing messages are NOT sent
This guarantees atomicity between state changes and messaging.
In-Process Command Handling (Mediator Pattern)
app.MapGet("/test", async ([FromServices] IMessageBus bus) =>
{
await bus.InvokeAsync(new ItemCommand { Id = 444 });
});
Notes
- Inject IMessageBus
- Use InvokeAsync
- Executes in-process
- If handler returns an output, Wolverine will try to find a matching handler for it
Asynchronous Messaging & Routing (SendAsync)
Command with Exactly One Handler
app.MapGet("/test", async ([FromServices] IMessageBus bus) =>
{
await bus.SendAsync(new ItemCommand { Id = 444 });
});
Characteristics
- Uses SendAsync
- Exactly one handler required
- If no handler found, will throw exception
- Runs in background
- Durable (Inbox / Outbox supported)
- To internal service or external brokers
Event with Multiple or Optional Handlers(PublishAsync)
app.MapGet("/test", async ([FromServices] IMessageBus bus) =>
{
await bus.PublishAsync(new ItemEvent { Id = 444 });
});
Characteristics
- Handler is not mandatory
- Multiple handlers allowed
- Runs in background
- Durable(Inbox / Outbox supported)
- To internal service or external brokers
Scheduled / Delayed Messages
// One-off Delay
await bus.ScheduleAsync( new ItemCommand { Id = 444 },
TimeSpan.FromMinutes(10) );
// with absolute time:
await bus.ScheduleAsync(new ExpireOrder { OrderId = id },
DateTimeOffset.UtcNow.AddHours(1));
Use cases:
- Delayed processing
- Time-based workflows
- Retry with backoff
- Not Repeating
- Survives restarts
- Durable ( Outbox / SqlServer)
Wolverine Scheduled Jobs (Cron-style)
Wolverine has a separate concept called Scheduled Jobs. These are not messages, but background jobs that can send messages.
Sample Scheduled Job
public class CleanupExpiredOrders
{
public async Task ExecuteAsync(IMessageBus bus)
{
await bus.SendAsync(new CleanupExpiredOrdersCommand());
}
}
Register with Cron Expression
builder.Host.UseWolverine(opts =>
{
opts.Schedule.CronJob<CleanupExpiredOrders>("0 */5 * * * ?");
});
👉 This cron expression uses Quartz-style cron.
Cron Expression Support (Important)
- Format: Quartz cron, , not Linux cron
- Supports seconds field
- Example:
- Every 5 minutes
0 */5 * * * ? - Every day at 02:00
0 0 2 * * ?
Competing Consumers & Scaling
- Wolverine supports multiple consumers on the same queue
- Messages are load-balanced automatically
- Enables horizontal scaling without extra configuration
Middleware Pipeline (Russian Doll Model)
Wolverine middleware is similar in concept to ASP.NET Core but implemented differently.
Example Middleware
public static class StopwatchMiddleware
{
public static Stopwatch Before()
{
var sw = Stopwatch.StartNew();
return sw;
}
public static void Finally( Stopwatch stopwatch, ILogger logger, Envelope envelope)
{
stopwatch.Stop();
logger.LogDebug( "Envelope {Id} / {MessageType} ran in {Duration} ms",
envelope.Id, envelope.MessageType, stopwatch.ElapsedMilliseconds);
}
}
Supported Middleware Method Conventions
|
Lifecycle Phase |
Method Names |
|
Before handler |
|
|
After handler |
|
|
Finally |
|
Generated Execution Flow
middleware.Before();
try
{
// handler execution
middleware.After();
}
finally
{
middleware.Finally();
}
Register Middleware by Message Type
builder.Host.UseWolverine(opts =>
{
opts.Policies
.ForMessagesOfType<IAccountCommand>()
.AddMiddleware(typeof(AccountLookupMiddleware));
});
Apply Middleware Explicitly via Attribute
public static class SomeHandler
{
[Middleware(typeof(StopwatchMiddleware))]
public static void Handle(PotentiallySlowMessage message)
{
// handler logic
}
}
Enforce Durable Outbox Globally
Activate Inbox / Outbox (Durable Messaging)
dotnet add package WolverineFx.SqlServer
builder.Host.UseWolverine(options =>
{
options.Policies.UseDurableOutboxOnAllSendingEndpoints();
});
This forces all outgoing messages to use Inbox / Outbox pattern.
Automatic Retries
- Failed message handling is retried automatically
- Retry count and delay can be configured per message type or endpoint
opts.Policies .ForMessagesOfType<ItemCommand>() .Retry(3);
Dead Letter Queue
- Messages that exceed retry limits are moved to dead-letter storage
- Can be inspected and replayed
Idempotency & Inbox Behavior
- Inbox ensures a message is processed only once
- Duplicate messages are ignored safely
- Especially important for:
- External brokers
- At-least-once delivery systems
Inbox works automatically when durable messaging is enabled.
Message Broker
dotnet add package WolverineFx.RabbitMQ
dotnet add package WolverineFx.AzureServiceBus
dotnet add package WolverineFx.Kafka
Use RabbitMQ
builder.Host.UseWolverine(options =>
{
options
.UseRabbitMq(new Uri("amqp://localhost:5672"))
.AutoProvision();
});
Use Kafka
builder.Host.UseWolverine(opts =>
{
opts.UseKafka(kafka =>
{
kafka.BootstrapServers = "localhost:9092";
kafka.Configure(config =>
{
config.SecurityProtocol = SecurityProtocol.SaslPlaintext;
// or: SecurityProtocol.SaslSsl
config.SaslMechanism = SaslMechanism.Plain;
config.SaslUsername ="my-user";
config.SaslPassword ="my-password";
// Recommended production defaults
config.Acks = Acks.All;
config.EnableIdempotence = true;
});
});
});
Observability & Diagnostics
- Built-in structured logging per Envelope
- Envelope ID enables end-to-end tracing
- Middleware can be used for:
- Metrics
- Timing
- Correlation IDs
Goal
- End-to-end message tracing
- Clear visibility into latency, failures, and retries
- Correlation across HTTP → Message → Handler → Database
1) Correlation ID (Non-Negotiable)
Rule
Every message must have a CorrelationId.
- Either propagated from HTTP headers
- Or generated at the message boundary
Correlation & Logging Scope Middleware
public static class CorrelationMiddleware
{
public static void Before(Envelope envelope, ILogger logger)
{
var correlationId = envelope.CorrelationId ?? Guid.NewGuid().ToString("N");
envelope.CorrelationId = correlationId;
logger.BeginScope(new Dictionary<string, object>
{
["CorrelationId"] = correlationId,
["MessageType"] = envelope.MessageType,
["EnvelopeId"] = envelope.Id
});
}
}
Global Registration
builder.Host.UseWolverine(opts =>
{
opts.Policies .ForAllMessages()
.AddMiddleware(typeof(CorrelationMiddleware));
});
Result
- All logs share the same correlation context
- Distributed debugging becomes feasible
2) Latency & Performance Measurement
Allocation-Friendly Timing Middleware
public static class TimingMiddleware
{
public static ValueStopwatch Before() => ValueStopwatch.StartNew();
public static void Finally( ValueStopwatch stopwatch, Envelope envelope, ILogger logger)
{
logger.LogInformation( "Message {MessageType} ({EnvelopeId}) executed in {Elapsed} ms",
envelope.MessageType,
envelope.Id,
stopwatch.GetElapsedTime().TotalMilliseconds);
}
}
Why this matters:
- Handler latency is often the first early-warning signal
- p95 / p99 tells you more than averages
3) Failure Visibility (No Silent Errors)
Failure Logging Middleware
public static class ErrorLoggingMiddleware
{
public static void Finally( Envelope envelope, ILogger logger, Exception? exception)
{
if (exception == null) return;
logger.LogError( exception,
"Message failed: {MessageType} / Envelope {EnvelopeId}",
envelope.MessageType,
envelope.Id);
}
}
Outcome
- Exceptions are never swallowed
- Retries and dead-letter events are diagnosable
4) Retry Telemetry
Retry Policy with Observability Hooks
builder.Host.UseWolverine(opts =>
{
opts.Policies .ForAllMessages()
.Retry(3)
.OnRetry((ex, attempt, env) =>
{
var logger = opts.Services.GetRequiredService<ILoggerFactory>() .CreateLogger("Wolverine.Retry");
logger.LogWarning( ex,
"Retry {Attempt} for {MessageType} / {EnvelopeId}",
attempt,
env.MessageType,
env.Id);
});
});
Why
- Retry storms are invisible without telemetry
- Retry count is a strong indicator of downstream instability
5) OpenTelemetry Tracing
Message-Level Tracing Middleware
public static class OpenTelemetryMiddleware
{
private static readonly ActivitySource Source = new("Wolverine.Messages");
public static Activity Before(Envelope envelope)
{
var activity = Source.StartActivity( envelope.MessageType, ActivityKind.Consumer);
activity?.SetTag("messaging.message_id", envelope.Id);
activity?.SetTag("messaging.correlation_id", envelope.CorrelationId);
return activity!;
}
public static void Finally(Activity activity)
{
activity?.Dispose();
}
}
Result
- Full visibility in Jaeger / Tempo / Application Insights
- Message handling appears as first-class trace spans
6) Metrics & Health Signals
Key signals you should expose:
- Queue depth
- Handler execution latency
- Retry rate
- Dead-letter count
Metrics Middleware Example
public static class MetricsMiddleware
{
public static void Finally( Envelope envelope, IMetrics metrics)
{
metrics.Measure.Counter.Increment( MetricsRegistry.ProcessedMessages,
tags: new[] { $"type:{envelope.MessageType}" });
}
}
7) Non-Negotiable Observability Checklist
If any of these are missing, you do not have observability:
- ❌ Correlation IDs
- ❌ Structured logging
- ❌ Latency tracking
- ❌ Retry visibility
- ❌ Distributed tracing
Wolverine enables these.
It does not configure them for you.
Message Versioning
Best practices:
- Avoid breaking changes in message contracts
- Use versioned message types:
ItemCommandV2
- Or additive changes only
Strategy 1⃣ — Explicit Versioned Message Types (Recommended)
V1 Message (Already in Production)
public record CreateOrder
{
public Guid OrderId { get; init; }
public decimal Amount { get; init; }
}
V1 Handler
public class CreateOrderHandler
{
public Task Handle(CreateOrder command)
{
// V1 logic
return Task.CompletedTask;
}
}
V2 Message (Additive Change)
public record CreateOrderV2
{
public Guid OrderId { get; init; }
public decimal Amount { get; init; }
public string Currency { get; init; } = "USD";
}
V2 Handler
public class CreateOrderV2Handler
{
public Task Handle(CreateOrderV2 command)
{
// V2 logic
return Task.CompletedTask;
}
}
Coexistence (Critical)
- V1 producers keep working
- V2 producers opt-in
- Zero downtime
- No conditional logic in handlers
✅ This is the safest pattern
Strategy 2 — Event Versioning (Immutable History)
Events are forever.
Never change old ones.
V1 Event
public record OrderPlaced
{
public Guid OrderId { get; init; }
}
V2 Event
public record OrderPlacedV2
{
public Guid OrderId { get; init; }
public DateTime PlacedAt { get; init; }
}
Consumer Supporting Both
public class AccountingConsumer
{
public Task Handle(OrderPlaced e)
{
return HandleInternal(e.OrderId, DateTime.MinValue);
}
public Task Handle(OrderPlacedV2 e)
{
return HandleInternal(e.OrderId, e.PlacedAt);
}
private Task HandleInternal(Guid orderId, DateTime placedAt)
{
return Task.CompletedTask;
}
}
💡If using JSON serialization:
- ✅ Adding fields is safe
- ❌ Renaming fields is breaking
- ❌ Changing types is breaking
Safe Evolution
public record PaymentProcessed
{
public Guid PaymentId { get; init; }
public decimal Amount { get; init; }
public string? ProviderReference { get; init; }
}
Old consumers ignore unknown fields.
New consumers get extra data.
What NOT to Do ❌ (Real Anti-Patterns)
❌ Conditional Logic in Handler
if (command.Version == 1) { ... }
else { ... }
This rots fast and becomes untestable.
❌ Reusing Message Type Name
public record CreateOrder // modified fields
Old messages in queues will break.
❌ Removing Fields
Inbox + retries + delayed messages = 💥
Deployment Rule (Non-Negotiable)
Safe rollout order
- Deploy consumers that support both versions
- Deploy new producers
- Monitor
- Remove old version later (optional)
Never reverse this order.
💡Versioning Checklist
- ✅ Messages treated as public contracts
- ✅ Additive changes only
- ✅ Versioned types for breaking changes
- ❌ No mutation of historical events
Bottom Line
If your system:
- Is distributed
- Uses retries
- Uses durable messaging
Then message versioning is mandatory.
Wolverine doesn’t save you from bad contracts.
But it gives you clean tools to evolve them safely.
Testing Wolverine Handlers (Missing)
In-Memory Message Bus
using var host = await Host.CreateDefaultBuilder()
.UseWolverine()
.StartAsync();
await host .Services
.GetRequiredService<IMessageBus>()
.InvokeAsync(new ItemCommand());
- No external broker required
- Suitable for unit and integration tests
- Handlers execute synchronously for predictability
✅ Summary
- InvokeAsync → synchronous, in-process (mediator style)
- SendAsync → asynchronous, durable, background execution
- Middleware supports rich lifecycle hooks
- Built-in support for Inbox / Outbox and external brokers