Skip to content

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.

  • DirectoryGranit.Wolverine/ Core: routing, context propagation, FluentValidation middleware
    • Granit.Wolverine.Postgresql PostgreSQL outbox, EF Core transactions
PackageRoleDepends on
Granit.WolverineDomain event routing, context propagation, validationGranit.Users
Granit.Wolverine.PostgresqlPostgreSQL transactional outbox, EF Core integrationGranit.Wolverine, Granit.Persistence
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=..."
}
}
Domain eventIntegration event
InterfaceIDomainEventIIntegrationEvent
NamingPatientDischargedOccurredBedReleasedEvent
ScopeIn-process, same transactionCross-module, durable
TransportLocal queue ("domain-events")Outbox → PostgreSQL transport
RetryYes (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;

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).

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.

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
HeaderSourceBehavior
X-Tenant-IdICurrentTenant.IdTenantContextBehavior restores AsyncLocal
X-User-IdICurrentUserService.UserIdUserContextBehavior restores via WolverineCurrentUserService
X-Actor-KindICurrentUserService.ActorKindUser, ExternalSystem, or System
X-Api-Key-IdICurrentUserService.ApiKeyIdService account identification
traceparentActivity.Current?.IdW3C Trace Context for distributed tracing

This means AuditedEntityInterceptor populates CreatedBy/ModifiedBy correctly even in background handlers — the user context travels with the message.

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:

ModeBehaviorUse case
Eager (default)Explicit BeginTransactionAsync()ISO 27001 compliance
LightweightSaveChangesAsync()-level isolationNon-critical operations

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 call
var report = await claimCheck.ConsumePayloadAsync<byte[]>(
reference, cancellationToken)
.ConfigureAwait(false);

Register with services.AddInMemoryClaimCheckStore() for development. Production implementations use blob storage.

{
"Wolverine": {
"RetryDelays": ["00:00:05", "00:00:30", "00:05:00"],
"MaxRetryAttempts": 3
}
}
PropertyDefaultDescription
RetryDelays[5s, 30s, 5min]Delay between retries
MaxRetryAttempts3Max attempts before dead letter

ValidationException bypasses retries entirely — sent directly to the error queue.

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):

ModeBehaviorWhen
Dynamic (default)Roslyn compiles handler code at startupLocal development
StaticLoads pre-generated code from the assembly; no runtime fallbackProduction
AutoGenerates on first run if missing, then reuses itAccepted, but discouraged by the Wolverine community

Set the mode in appsettings.Production.json. No code change is needed — Granit binds it onto opts.CodeGeneration.TypeLoadMode for you:

{
"Wolverine": {
"CodeGenerationMode": "Static"
}
}
  1. Wire the JasperFx CLI into Program.cs. Replace the final await app.RunAsync(); with the JasperFx command runner so the codegen verb 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 run boots normally. Keep any external-resource calls (database, broker) after this line: codegen write boots the host only far enough to discover handlers and must not reach infrastructure that is unavailable at build time.

  2. Generate the code as a build step. Run the codegen verb before publishing — a dedicated Docker build stage, or a CI step ahead of dotnet publish:

    Terminal window
    dotnet run -- codegen write

    This writes the handler and endpoint sources under Internal/Generated/ (Wolverine handlers land in Internal/Generated/WolverineHandlers/).

  3. 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.

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.

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 not required to use Granit. These modules have built-in Channel<T> fallbacks when Wolverine is not installed:

ModuleWithout WolverineWith Wolverine
Granit.BackgroundJobsIn-memory ChannelDurable outbox
Granit.NotificationsIn-memory ChannelDurable outbox
Granit.WebhooksIn-memory ChannelDurable outbox
Granit.DataExchangeIn-memory ChannelDurable outbox
Granit.Persistence.MigrationsIn-memory ChannelDurable outbox
CategoryKey typesPackage
ModuleGranitWolverineModule, GranitWolverinePostgresqlModule
OptionsWolverineMessagingOptions, WolverinePostgresqlOptions
ContextOutgoingContextMiddleware, TenantContextBehavior, UserContextBehavior, TraceContextBehaviorGranit.Wolverine
User serviceWolverineCurrentUserService (internal, implements ICurrentUserService)Granit.Wolverine
Claim CheckIClaimCheckStore, ClaimCheckReferenceGranit.Wolverine
Attributes[WolverineHandlerModule]Granit.Wolverine
ExtensionsAddGranitWolverine(), AddGranitWolverineWithPostgresql(), AddGranitWolverineWithPostgresqlPerTenant<T>(), AddInMemoryClaimCheckStore()
SymptomLikely causeFix
Message published but handler never runsHandler not discoveredAdd [assembly: WolverineHandlerModule] to the assembly
TenantId is null in handlerContext middleware not configuredVerify AddGranitWolverine() is called
Messages retry indefinitelyNo dead letter policy configuredCheck WithFailureRules() in Wolverine options
PostgreSQL transport errors on startupMissing PostgreSQL transport registrationCall AddGranitWolverineWithPostgresql() instead of AddGranitWolverine()
Slow message throughputToo many handlers in same queueSplit into dedicated queues with [Queue("name")]