Wolverine — Messaging & Command Bus for .NET
Granit.Wolverine integrates the Wolverine message bus into the module system. Domain events route to local queues, integration events persist in a transactional outbox, and tenant/user/trace context propagates automatically across async message processing. FluentValidation runs as bus middleware — invalid commands go straight to the error queue, no retry.
Package structure
Section titled “Package structure”DirectoryGranit.Wolverine/ Core: routing, context propagation, FluentValidation middleware
- Granit.Wolverine.Postgresql PostgreSQL outbox, EF Core transactions
| Package | Role | Depends on |
|---|---|---|
Granit.Wolverine | Domain event routing, context propagation, validation | Granit.Users |
Granit.Wolverine.Postgresql | PostgreSQL transactional outbox, EF Core integration | Granit.Wolverine, Granit.Persistence |
Dependency graph
Section titled “Dependency graph”graph TD
W[Granit.Wolverine] --> S[Granit.Users]
WP[Granit.Wolverine.Postgresql] --> W
WP --> P[Granit.Persistence]
[DependsOn(typeof(GranitWolverinePostgresqlModule))]public class AppModule : GranitModule { }{ "Wolverine": { "MaxRetryAttempts": 3, "RetryDelays": ["00:00:05", "00:00:30", "00:05:00"] }, "Wolverine:Postgresql": { "TransportConnectionString": "Host=db;Database=myapp;Username=app;Password=..." }}[DependsOn(typeof(GranitWolverineModule))]public class AppModule : GranitModule { }No outbox — messages are in-memory only. Lost on crash.
[DependsOn(typeof(GranitWolverinePostgresqlModule))]public class AppModule : GranitModule{ public override void ConfigureServices(ServiceConfigurationContext context) { context.Builder.AddGranitWolverineWithPostgresqlPerTenant<AppDbContext>(); }}Each tenant’s messages persist in their own database (strictest ISO 27001 isolation).
Domain events vs integration events
Section titled “Domain events vs integration events”| Domain event | Integration event | |
|---|---|---|
| Interface | IDomainEvent | IIntegrationEvent |
| Naming | PatientDischargedOccurred | BedReleasedEvent |
| Scope | In-process, same transaction | Cross-module, durable |
| Transport | Local queue ("domain-events") | Outbox → PostgreSQL transport |
| Retry | Yes (configurable delays) | Yes (at-least-once delivery) |
public sealed record PatientDischargedOccurred( Guid PatientId, Guid BedId) : IDomainEvent;
public sealed record BedReleasedEvent( Guid BedId, Guid WardId, DateTimeOffset ReleasedAt) : IIntegrationEvent;Handler conventions
Section titled “Handler conventions”Discovery
Section titled “Discovery”Mark handler assemblies with [WolverineHandlerModule] — Granit auto-discovers
handlers and validators:
[assembly: WolverineHandlerModule]
namespace MyApp.Handlers;
public static class DischargePatientHandler{ public static IEnumerable<object> Handle( DischargePatientCommand command, PatientDbContext db) { var patient = db.Patients.Find(command.PatientId) ?? throw new EntityNotFoundException(typeof(Patient), command.PatientId);
patient.Discharge();
// Domain event — same transaction yield return new PatientDischargedOccurred(patient.Id, patient.BedId);
// Integration event — persisted in outbox yield return new BedReleasedEvent( patient.BedId, patient.WardId, DateTimeOffset.UtcNow); }}Handlers returning IEnumerable<object> produce multiple outbox messages atomically
(fan-out pattern).
FluentValidation
Section titled “FluentValidation”Validators run as bus middleware before handler execution:
public class DischargePatientCommandValidator : AbstractValidator<DischargePatientCommand>{ public DischargePatientCommandValidator() { RuleFor(x => x.PatientId).NotEmpty(); }}ValidationException goes directly to the error queue — no retry. Other exceptions
follow the retry policy.
Context propagation
Section titled “Context propagation”Three contexts automatically propagate through message envelopes:
sequenceDiagram
participant HTTP as HTTP Request
participant Out as OutgoingMiddleware
participant Env as Message Envelope
participant In as Incoming Behaviors
participant Handler as Handler
HTTP->>Out: TenantId, UserId, TraceId
Out->>Env: X-Tenant-Id, X-User-Id, traceparent
Env->>In: Read headers
In->>Handler: ICurrentTenant, ICurrentUserService, Activity
| Header | Source | Behavior |
|---|---|---|
X-Tenant-Id | ICurrentTenant.Id | TenantContextBehavior restores AsyncLocal |
X-User-Id | ICurrentUserService.UserId | UserContextBehavior restores via WolverineCurrentUserService |
X-Actor-Kind | ICurrentUserService.ActorKind | User, ExternalSystem, or System |
X-Api-Key-Id | ICurrentUserService.ApiKeyId | Service account identification |
traceparent | Activity.Current?.Id | W3C Trace Context for distributed tracing |
This means AuditedEntityInterceptor populates CreatedBy/ModifiedBy correctly
even in background handlers — the user context travels with the message.
Transactional outbox
Section titled “Transactional outbox”The PostgreSQL outbox guarantees at-least-once delivery by persisting messages in the same transaction as business data:
sequenceDiagram
participant H as Handler
participant DB as PostgreSQL
participant O as Outbox
participant T as Transport
H->>DB: UPDATE patients SET ...
H->>O: INSERT outbox message
H->>DB: COMMIT (atomic)
Note over DB,O: Both succeed or both rollback
O->>T: Dispatch post-commit
T->>O: ACK → DELETE from outbox
Transaction modes:
| Mode | Behavior | Use case |
|---|---|---|
Eager (default) | Explicit BeginTransactionAsync() | ISO 27001 compliance |
Lightweight | SaveChangesAsync()-level isolation | Non-critical operations |
Claim Check pattern
Section titled “Claim Check pattern”For large payloads that shouldn’t travel through the message bus:
public class LargeReportHandler(IClaimCheckStore claimCheck){ public async Task<ClaimCheckReference> Handle( GenerateReportCommand command, CancellationToken cancellationToken) { byte[] reportData = await GenerateReportAsync(command, cancellationToken) .ConfigureAwait(false);
return await claimCheck.StorePayloadAsync( reportData, expiry: TimeSpan.FromHours(1), cancellationToken) .ConfigureAwait(false); }}
// Consumer retrieves and deletes in one callvar report = await claimCheck.ConsumePayloadAsync<byte[]>( reference, cancellationToken) .ConfigureAwait(false);Register with services.AddInMemoryClaimCheckStore() for development.
Production implementations use blob storage.
Retry policy
Section titled “Retry policy”{ "Wolverine": { "RetryDelays": ["00:00:05", "00:00:30", "00:05:00"], "MaxRetryAttempts": 3 }}| Property | Default | Description |
|---|---|---|
RetryDelays | [5s, 30s, 5min] | Delay between retries |
MaxRetryAttempts | 3 | Max attempts before dead letter |
ValidationException bypasses retries entirely — sent directly to the error queue.
Production code generation
Section titled “Production code generation”Wolverine generates the C# that wires each handler and HTTP endpoint into the bus. By
default Granit runs in Dynamic mode: that code is compiled at runtime with Roslyn
(via WolverineFx.RuntimeCompilation, referenced transitively by Granit.Wolverine).
That is convenient locally, but every cold start recompiles every handler and endpoint
chain — paying both startup latency and ~100 MB of resident Roslyn. In a scaled-out
deployment that cost is repeated on every replica, on every restart.
Granit.Wolverine exposes the mode through WolverineMessagingOptions.CodeGenerationMode
(bound from Wolverine:CodeGenerationMode, type JasperFx.CodeGeneration.TypeLoadMode):
| Mode | Behavior | When |
|---|---|---|
Dynamic (default) | Roslyn compiles handler code at startup | Local development |
Static | Loads pre-generated code from the assembly; no runtime fallback | Production |
Auto | Generates on first run if missing, then reuses it | Accepted, but discouraged by the Wolverine community |
Opt in to Static
Section titled “Opt in to Static”Set the mode in appsettings.Production.json. No code change is needed — Granit binds it
onto opts.CodeGeneration.TypeLoadMode for you:
{ "Wolverine": { "CodeGenerationMode": "Static" }}Pre-generate the code in your build
Section titled “Pre-generate the code in your build”-
Wire the JasperFx CLI into
Program.cs. Replace the finalawait app.RunAsync();with the JasperFx command runner so thecodegenverb becomes available:var app = builder.Build();// ... middleware, UseGranitAsync(), etc. ...return await app.RunJasperFxCommands(args);With no arguments this still starts the web host exactly as before — confirm
dotnet runboots normally. Keep any external-resource calls (database, broker) after this line:codegen writeboots the host only far enough to discover handlers and must not reach infrastructure that is unavailable at build time. -
Generate the code as a build step. Run the
codegenverb before publishing — a dedicated Docker build stage, or a CI step ahead ofdotnet publish:Terminal window dotnet run -- codegen writeThis writes the handler and endpoint sources under
Internal/Generated/(Wolverine handlers land inInternal/Generated/WolverineHandlers/). -
Ship
Internal/Generated/with the image. The generated files must be compiled into the published assembly. Either commit them, or generate them in the build stage and copy them into the publish output — either way they travel with the deployed artifact.
Fail fast on stale code (optional)
Section titled “Fail fast on stale code (optional)”To turn a silent mismatch into a loud startup error, assert that every expected
pre-generated type exists. This flag lives on the Critter Stack production profile, not on
the Granit option — the mode itself stays controlled by Wolverine:CodeGenerationMode;
this only adds the existence check:
builder.Services.CritterStackDefaults(x =>{ x.Production.AssertAllPreGeneratedTypesExist = true;});Now a handler added after the last codegen write fails the host immediately at
startup, instead of surfacing as a missing-type error deep in message processing.
Keep Roslyn out of the production image
Section titled “Keep Roslyn out of the production image”Granit.Wolverine pulls in WolverineFx.RuntimeCompilation transitively so Dynamic mode
works out of the box. Static mode never compiles at runtime, so that ~100 MB of Roslyn is
dead weight in a production image. Strip the transitive assets from Release builds while
keeping them for local Dynamic development:
<!-- Roslyn runtime codegen is used only by Dynamic mode (local dev). Production runs Static + pre-generated code, so drop the transitive WolverineFx.RuntimeCompilation assets from Release builds. --><ItemGroup Condition="'$(Configuration)' == 'Release'"> <PackageReference Include="WolverineFx.RuntimeCompilation" ExcludeAssets="all" /></ItemGroup>Wolverine is optional
Section titled “Wolverine is optional”Wolverine is not required to use Granit. These modules have built-in Channel<T>
fallbacks when Wolverine is not installed:
| Module | Without Wolverine | With Wolverine |
|---|---|---|
Granit.BackgroundJobs | In-memory Channel | Durable outbox |
Granit.Notifications | In-memory Channel | Durable outbox |
Granit.Webhooks | In-memory Channel | Durable outbox |
Granit.DataExchange | In-memory Channel | Durable outbox |
Granit.Persistence.Migrations | In-memory Channel | Durable outbox |
Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Module | GranitWolverineModule, GranitWolverinePostgresqlModule | — |
| Options | WolverineMessagingOptions, WolverinePostgresqlOptions | — |
| Context | OutgoingContextMiddleware, TenantContextBehavior, UserContextBehavior, TraceContextBehavior | Granit.Wolverine |
| User service | WolverineCurrentUserService (internal, implements ICurrentUserService) | Granit.Wolverine |
| Claim Check | IClaimCheckStore, ClaimCheckReference | Granit.Wolverine |
| Attributes | [WolverineHandlerModule] | Granit.Wolverine |
| Extensions | AddGranitWolverine(), AddGranitWolverineWithPostgresql(), AddGranitWolverineWithPostgresqlPerTenant<T>(), AddInMemoryClaimCheckStore() | — |
Common pitfalls
Section titled “Common pitfalls”Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause | Fix |
|---|---|---|
| Message published but handler never runs | Handler not discovered | Add [assembly: WolverineHandlerModule] to the assembly |
TenantId is null in handler | Context middleware not configured | Verify AddGranitWolverine() is called |
| Messages retry indefinitely | No dead letter policy configured | Check WithFailureRules() in Wolverine options |
| PostgreSQL transport errors on startup | Missing PostgreSQL transport registration | Call AddGranitWolverineWithPostgresql() instead of AddGranitWolverine() |
| Slow message throughput | Too many handlers in same queue | Split into dedicated queues with [Queue("name")] |
See also
Section titled “See also”- End-to-end tracing guide — trace Wolverine handlers with OpenTelemetry
- ADR-005: Wolverine + Cronos — Why Wolverine was chosen for messaging and CQRS
- ADR-022: ICommandSender + module naming — command dispatch abstraction and
.Wolverinesuffix policy - Core module —
IDomainEvent,IIntegrationEvent,IDomainEventDispatcher - Persistence module —
DomainEventDispatcherInterceptor, transactional outbox - Multi-tenancy module — Tenant context propagation
- Security module —
ICurrentUserServicepropagation - API Reference (auto-generated from XML docs)
- Wolverine: Code Generation guide — upstream reference for
TypeLoadMode, thecodegenCLI, and pre-built types - Blog: CQRS without MediatR — how Granit uses Wolverine — the design rationale behind dropping MediatR in favor of Wolverine