Outbox Migration: Wolverine → Whizbang¶
Verified by tests
FlushApiTests, EFCoreWorkCoordinatorSchemaTests, DapperWorkCoordinatorBroadTests — library CI run #31657041675 (2026-08-13)
This guide covers migrating from Wolverine's durable outbox pattern to Whizbang's built-in outbox implementation.
Understanding the Outbox Pattern¶
The outbox pattern ensures reliable message delivery by:
- Writing events to a database table (outbox) in the same transaction as business data
- A background worker reads from the outbox and publishes to the message broker
- On successful publish, marks the outbox entry as completed
This guarantees at-least-once delivery even if the message broker is temporarily unavailable.
Key Differences¶
| Aspect | Wolverine | Whizbang |
|---|---|---|
| Configuration | UseDurableOutbox() |
Built-in, always enabled |
| Outbox table | wolverine_outgoing_envelopes |
wh_outbox |
| Inbox table | wolverine_incoming_envelopes |
wh_inbox |
| Background workers | Wolverine daemon | Registered automatically by AddWhizbang() (OutboxPublishWorker, InboxDispatchWorker, drain workers, etc.) |
| Batching/flush policy | Configurable | Configurable via WorkCoordinatorOptions (Immediate, Scoped, Interval, Batch strategies) |
Wolverine Outbox Configuration¶
Wolverine Outbox Configuration
// Wolverine: Explicit outbox configuration
builder.Host.UseWolverine(opts => {
opts.UseRabbitMq(connectionString)
.UseConventionalRouting()
.UseDurableOutbox(); // Enable outbox
opts.Policies.UseDurableLocalQueues();
opts.Policies.UseDurableInbox();
// Configure retry
opts.Handlers.OnAnyException()
.RetryWithCooldown(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5))
.Then.Requeue();
});
Whizbang Outbox Configuration¶
Whizbang's outbox is built-in and always enabled. AddWhizbang() registers all background workers (outbox publishing, inbox dispatch, drain workers, dead-letter recovery) automatically — you never add hosted services yourself. Tune behavior through WorkCoordinatorOptions:
Whizbang Outbox Configuration
builder.Services
.AddWhizbang()
.WithEFCore<AppDbContext>()
.WithDriver.Postgres;
// Optional: tune the work coordinator (defaults shown)
builder.Services.Configure<WorkCoordinatorOptions>(options => {
options.Strategy = WorkCoordinatorStrategy.Scoped; // Immediate | Scoped | Interval | Batch
options.IntervalMilliseconds = 100; // used when Strategy = Interval
options.BatchSize = 100; // used when Strategy = Batch
options.PartitionCount = 10_000;
options.DebugMode = false; // true keeps completed rows for debugging
});
Outbox Flow Comparison¶
Wolverine Flow¶
Wolverine Flow
// Wolverine: Messages queued to outbox automatically
[WolverineHandler]
public async Task<OrderCreated> Handle(
CreateOrder command,
IDocumentSession session,
IMessageContext context) {
var @event = new OrderCreated(command.OrderId);
// Append event (transactional)
session.Events.Append(command.OrderId, @event);
// Queue message to outbox (same transaction)
await context.PublishAsync(new NotifyCustomer(command.CustomerEmail));
await session.SaveChangesAsync(); // Commits both
return @event;
}
Whizbang Flow¶
Whizbang Flow
// Whizbang: Outbox is implicit - events returned from a receptor are
// automatically cascaded to the event store and outbox by the dispatcher.
public class CreateOrderReceptor : IReceptor<CreateOrder, OrderCreated> {
public ValueTask<OrderCreated> HandleAsync(
CreateOrder message,
CancellationToken ct = default) {
// Returning the event is all that's needed:
// the work coordinator persists it (wh_outbox + wh_event_store)
// atomically, and the outbox workers publish it to the transport.
return ValueTask.FromResult(new OrderCreated(message.OrderId));
}
}
// To publish an additional side-effect message explicitly, use the dispatcher -
// it also goes through the outbox:
await _dispatcher.PublishAsync(new NotifyCustomer(customerEmail));
// Routing control is available via Route.* when you need it:
// Route.Outbox(@event), Route.Both(@event), Route.EventStoreOnly(@event), ...
Inbox Pattern (Idempotency)¶
Wolverine Inbox¶
Wolverine Inbox
// Wolverine: Inbox for idempotent processing
opts.Policies.UseDurableInbox();
// Messages are tracked by ID in wolverine_incoming_envelopes
// Duplicate messages are automatically rejected
Whizbang Inbox¶
Whizbang Inbox
// Whizbang: Inbox deduplication is built-in - no configuration required.
// Messages received from the transport are recorded in wh_inbox and
// duplicates (same MessageId) are rejected before processing.
// Completed rows are deleted on completion; set DebugMode to keep them:
builder.Services.Configure<WorkCoordinatorOptions>(options => {
options.DebugMode = true; // keeps completed wh_inbox/wh_outbox rows for debugging
});
Work Coordinator Strategies¶
The strategy controls how buffered work (outbox writes, completions) is flushed to the database. Select it via WorkCoordinatorOptions.Strategy:
| Strategy | Behavior | Best for |
|---|---|---|
Immediate |
Flushes each operation immediately | Lowest latency, highest DB load |
Scoped (default) |
Batches within a scope (e.g., HTTP request), flushes on scope disposal | Balanced latency/efficiency |
Interval |
Batches and flushes on a timer (IntervalMilliseconds) |
High-throughput background workers |
Batch |
Flushes at BatchSize or after a debounce quiet period |
Bulk imports, seeding |
Selecting a Strategy
builder.Services.Configure<WorkCoordinatorOptions>(options => {
options.Strategy = WorkCoordinatorStrategy.Interval;
options.IntervalMilliseconds = 100;
options.CoalesceWindowMilliseconds = 50; // recommended for Interval strategy
});
Postgres LISTEN/NOTIFY wake-ups for new work are built into the Postgres drivers — there is no separate "notification strategy" to configure.
Error Handling¶
Wolverine Error Policies¶
Wolverine Error Policies
opts.Handlers.OnAnyException()
.RetryWithCooldown(
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(30))
.Then.MoveToErrorQueue();
Whizbang Retry Behavior¶
Retries are lease-based rather than policy-chain-based:
- Failed work is released back to the pool when its lease expires (
WorkCoordinatorOptions.LeaseSeconds, default 300) and is picked up again automatically. - Transports enforce
MaxDeliveryAttempts(default 10 on both RabbitMQ and Azure Service Bus options) before dead-lettering a message. - Rows that exhaust delivery move to the
wh_dead_letterstable, whereDeadLetterRecoveryWorkerapplies the configuredIDeadLetterRecoveryPolicy(re-emit, hold for review, or mark permanently failed).
Whizbang Retry Configuration
builder.Services.Configure<WorkCoordinatorOptions>(options => {
options.LeaseSeconds = 300; // lease before failed work is reclaimed
options.AbandonStaleInstanceThresholdSeconds = 30; // dead-instance detection
});
builder.Services.AddRabbitMQTransport(connectionString, options => {
options.MaxDeliveryAttempts = 10; // then dead-letter
});
Dead Letter Queue¶
Wolverine Dead Letter¶
Wolverine Dead Letter
// Wolverine moves failed messages to error queue
// Access via IMessageStore.Inbox.DeadLetterEnvelopes
Whizbang Dead Letter¶
Whizbang Dead Letter
-- Failed messages are moved to the wh_dead_letters table.
-- Inspect them with SQL:
SELECT * FROM wh_dead_letters ORDER BY created_at DESC;
Recovery is automated: DeadLetterRecoveryWorker (registered by AddWhizbang()) periodically scans wh_dead_letters, applies the configured IDeadLetterRecoveryPolicy, and either re-emits rows to the source work table or marks them terminal (hold-for-review / permanently-failed). Transport-level DLQs (RabbitMQ dead-letter exchange, Service Bus DLQ subscriptions) are drained back into the pipeline by TransportDeadLetterDrainWorker.
Database Schema¶
Wolverine Tables¶
Wolverine Tables
-- Wolverine outbox tables
CREATE TABLE wolverine_outgoing_envelopes (...);
CREATE TABLE wolverine_incoming_envelopes (...);
Whizbang Tables¶
Whizbang Tables
-- Whizbang provisions its own infrastructure tables on startup
-- (prefix "wh_"); you never create them by hand. The messaging set includes:
-- wh_outbox -- outgoing messages awaiting publish (created_at timestamps)
-- wh_inbox -- received messages for dedup/processing (received_at timestamps)
-- wh_event_store -- append-only event streams
-- wh_dead_letters -- messages that exhausted delivery
-- wh_active_streams -- stream claim/ordering state
-- wh_service_instances -- instance heartbeat + partition assignment
Note: Rows in
wh_outbox/wh_inboxare deleted on successful completion in production. EnableWorkCoordinatorOptions.DebugModeto keep completed rows for debugging.
Transactional Consistency¶
Ensuring Atomicity¶
Atomicity is handled by the framework, not by user-managed transactions. When a receptor returns an event (or you dispatch through IDispatcher), the work coordinator persists the outbox row and the event-store row together in a single store_outbox_messages database call (the function copies newly-inserted outbox events into wh_event_store in the same statement) — there is no IOutbox.EnqueueAsync API to call and no transaction to manage:
Ensuring Atomicity
public class CreateOrderReceptor : IReceptor<CreateOrder, OrderCreated> {
public ValueTask<OrderCreated> HandleAsync(
CreateOrder message,
CancellationToken ct = default) {
// Event store write + outbox write happen atomically
// in the work coordinator - no explicit transaction needed.
return ValueTask.FromResult(new OrderCreated(message.OrderId));
}
}
Migration Checklist¶
- [ ] Remove
UseDurableOutbox()configuration - [ ] Remove
UseDurableInbox()configuration - [ ] Remove manual outbox enqueue calls (events returned from receptors cascade automatically)
- [ ] Optionally tune
WorkCoordinatorOptions(strategy, lease, batch size) - [ ] Set transport
MaxDeliveryAttemptsto match your old retry policy - [ ] Verify Whizbang provisions its schema on startup (
wh_outbox,wh_inbox,wh_event_store,wh_dead_letters) - [ ] Review dead-letter recovery policy (
IDeadLetterRecoveryPolicy) - [ ] Test transactional consistency
Previous: Transport Configuration | Next: Testing Migration