Skip to content

Wolverine Optionality

Not every project needs a full message bus. A small internal API serving a single team has no use for a PostgreSQL-backed transactional outbox, durable delivery, or distributed transport. But that same API might still need background jobs, webhook delivery, or notification dispatch.

Forcing Wolverine on every project would mean unnecessary infrastructure (PostgreSQL transport tables, outbox polling, dead-letter queues) for applications that will never need durability guarantees. On the other hand, baking in-memory-only dispatch into the framework would leave production systems without crash recovery.

Granit’s answer: make Wolverine optional everywhere, with a Channel<T> fallback that works out of the box.

Four modules ship with both a Wolverine integration package and a built-in Channel<T> dispatcher:

ModuleChannel dispatcherWolverine package
Granit.BackgroundJobsChannelBackgroundJobDispatcherGranit.BackgroundJobs.Wolverine
Granit.NotificationsChannelNotificationPublisherGranit.Notifications.Wolverine
Granit.WebhooksChannelWebhookCommandDispatcherGranit.Webhooks.Wolverine
Granit.DataExchangeChannelImportCommandDispatcher / ChannelExportCommandDispatcherGranit.DataExchange.Wolverine

Each base module registers its Channel<T> dispatcher by default. When the corresponding Wolverine package is added and its module is declared in [DependsOn], it replaces the Channel dispatcher with a durable outbox-backed implementation. No code changes in your handlers.

The pattern is the same across all four modules. Taking background jobs as an example:

// ChannelBackgroundJobDispatcher (simplified)
internal sealed class ChannelBackgroundJobDispatcher(
Channel<BackgroundJobEnvelope> channel,
TimeProvider timeProvider) : IBackgroundJobDispatcher
{
public async Task PublishAsync(
object message,
IDictionary<string, string>? headers = null,
CancellationToken cancellationToken = default) =>
await channel.Writer.WriteAsync(
new BackgroundJobEnvelope(message, headers), cancellationToken)
.ConfigureAwait(false);
}

A BackgroundJobWorker (hosted service) reads from the channel and executes handlers in-process. The same pattern applies to NotificationDispatchWorker, WebhookDispatchWorker, and the DataExchange workers.

RequirementWithout WolverineWith Wolverine
Fire-and-forget jobsChannel (in-memory)Durable outbox
Scheduled/recurring jobsTask.Delay (lost on crash)Cron + outbox (crash-safe)
At-least-once deliveryNoYes
Transactional outboxNoYes
Distributed tracing across async handlersNoYes (context propagation)
Horizontal scaling (multiple instances)No (in-process only)Yes (PostgreSQL transport)
Dead-letter queue inspectionNoYes (admin endpoints)
  • Internal tools and small APIs with a single instance
  • Development and testing environments
  • Applications where losing a few in-flight messages on crash is acceptable
  • Prototyping — get the feature working first, add durability later
  • Production systems that require at-least-once delivery (webhook delivery, payment notifications)
  • Multi-instance deployments where only one instance should run a recurring job
  • ISO 27001 environments that mandate audit trail completeness through crash recovery
  • Applications that need the transactional outbox to avoid dual-write problems

The migration path is deliberately simple. No handler code changes, no interface changes — just add packages and update [DependsOn].

[DependsOn(typeof(GranitBackgroundJobsModule))]
[DependsOn(typeof(GranitNotificationsModule))]
public class AppModule : GranitModule { }

The Wolverine modules transitively depend on their base modules, so GranitBackgroundJobsWolverineModule pulls in GranitBackgroundJobsModule automatically. The Channel dispatcher is replaced by the Wolverine dispatcher at DI registration time.

Add the PostgreSQL transport connection string:

{
"WolverinePostgresql": {
"TransportConnectionString": "Host=db;Database=myapp;Username=app;Password=..."
}
}

That is the entire migration. Your handlers, your cron expressions, your notification templates — everything else stays the same.

graph TD
    subgraph "Without Wolverine"
        H1[Handler] --> CD[Channel Dispatcher]
        CD --> CW[Channel Worker]
        CW --> H2[Handler execution]
    end

    subgraph "With Wolverine"
        H3[Handler] --> WD[Wolverine Dispatcher]
        WD --> OB[Outbox - same TX]
        OB --> TR[PostgreSQL Transport]
        TR --> H4[Handler execution]
    end

    style CD fill:#f9f,stroke:#333
    style WD fill:#9f9,stroke:#333

Both paths implement the same IBackgroundJobDispatcher / INotificationPublisher / IWebhookPublisher interfaces. Consumer code is identical.