Wolverine Optionality
The problem
Section titled “The problem”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.
The Channel fallback pattern
Section titled “The Channel fallback pattern”Four modules ship with both a Wolverine integration package and a built-in Channel<T> dispatcher:
| Module | Channel dispatcher | Wolverine package |
|---|---|---|
Granit.BackgroundJobs | ChannelBackgroundJobDispatcher | Granit.BackgroundJobs.Wolverine |
Granit.Notifications | ChannelNotificationPublisher | Granit.Notifications.Wolverine |
Granit.Webhooks | ChannelWebhookCommandDispatcher | Granit.Webhooks.Wolverine |
Granit.DataExchange | ChannelImportCommandDispatcher / ChannelExportCommandDispatcher | Granit.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.
How Channel dispatch works
Section titled “How Channel dispatch works”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.
When you need what
Section titled “When you need what”| Requirement | Without Wolverine | With Wolverine |
|---|---|---|
| Fire-and-forget jobs | Channel (in-memory) | Durable outbox |
| Scheduled/recurring jobs | Task.Delay (lost on crash) | Cron + outbox (crash-safe) |
| At-least-once delivery | No | Yes |
| Transactional outbox | No | Yes |
| Distributed tracing across async handlers | No | Yes (context propagation) |
| Horizontal scaling (multiple instances) | No (in-process only) | Yes (PostgreSQL transport) |
| Dead-letter queue inspection | No | Yes (admin endpoints) |
When Channel dispatch is enough
Section titled “When Channel dispatch is enough”- 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
When you need Wolverine
Section titled “When you need Wolverine”- 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
Error behavior
Section titled “Error behavior”The failure modes are fundamentally different — this is the main reason to upgrade from Channel to Wolverine in production.
// Handler throws — what happens next?public static async Task Handle(SendInvoiceNotificationCommand command){ throw new SmtpException("Mail server unreachable"); // ↑ The Channel worker catches the exception and logs it. // The message is gone. No retry. No dead letter. No record.}Timeline:
- Exception is thrown
BackgroundJobWorkerlogs the error- Message is discarded — permanently lost
- Application continues processing the next message
// Same handler, same exception — completely different outcomepublic static async Task Handle(SendInvoiceNotificationCommand command){ throw new SmtpException("Mail server unreachable"); // ↑ Wolverine catches the exception. // Retry 1 after 5s → Retry 2 after 30s → Retry 3 after 5min // If all retries fail → moved to dead-letter queue // Dead-letter queue is inspectable via admin endpoints}Timeline:
- Exception is thrown
- Wolverine marks the envelope as failed, schedules retry 1 (5s delay)
- Retry 1 fails → retry 2 (30s delay)
- Retry 2 fails → retry 3 (5 min delay)
- Retry 3 fails → message moved to dead-letter queue (PostgreSQL
wolverine_dead_letters) - Admin can inspect, replay, or discard dead-letter messages
Adding Wolverine later
Section titled “Adding Wolverine later”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 { }[DependsOn(typeof(GranitBackgroundJobsWolverineModule))][DependsOn(typeof(GranitNotificationsWolverineModule))][DependsOn(typeof(GranitWolverinePostgresqlModule))]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.
Architecture
Section titled “Architecture”graph TD
subgraph top["Without Wolverine"]
H1[Handler] --> CD[Channel Dispatcher]
CD --> CW[Channel Worker]
CW --> H2[Handler execution]
end
H2 ~~~ H3
subgraph bot["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.
See also
Section titled “See also”- Messaging — domain events, integration events, transactional outbox, context propagation
- Wolverine reference — full API surface, setup variants, handler conventions
- BackgroundJobs reference — recurring job scheduling with cron expressions