Skip to content

Dispatch Patterns

Verified by tests

DispatcherTests, DispatcherInvokeWithReceiptTests, DeliveryReceiptTests, DispatcherOutboxTests, DispatcherSyncTests, DispatcherCascadeTests — library CI run #31657041675 (2026-08-13)

Deep-dive reference for each dispatch pattern. For the decision matrix, interface overview, and configuration, see the Dispatcher overview.

Pattern 1: SendAsync - Command Dispatch

Use Case: Send commands with delivery tracking, supports both local and remote dispatch.

Signature: Pattern 1: SendAsync - Command Dispatch

Task<IDeliveryReceipt> SendAsync<TMessage>(
    TMessage message
) where TMessage : notnull;

Returns: IDeliveryReceipt containing message ID, correlation ID, destination, status, and metadata.

Basic Usage

Basic Usage

public class OrdersController : ControllerBase {
    private readonly IDispatcher _dispatcher;

    public OrdersController(IDispatcher dispatcher) {
        _dispatcher = dispatcher;
    }

    [HttpPost]
    public async Task<ActionResult> CreateOrder(
        [FromBody] CreateOrderRequest request,
        CancellationToken ct) {

        var command = new CreateOrder(
            CustomerId: request.CustomerId,
            Items: request.Items
        );

        // Send command, get delivery receipt
        var receipt = await _dispatcher.SendAsync(command);

        return Accepted(new {
            messageId = receipt.MessageId,
            correlationId = receipt.CorrelationId,
            timestamp = receipt.Timestamp
        });
    }
}

Verified example

This snippet is verbatim from the library's test suite and drift-checked in CI — if the test changes, the docs build flags this page:

var command = new CreateOrder(Guid.NewGuid(), ["item1", "item2"]);

// Act
var receipt = await dispatcher.SendAsync(command);

// Assert
await Assert.That(receipt).IsNotNull();
await Assert.That(receipt.MessageId.Value).IsNotEqualTo(Guid.Empty);
await Assert.That(receipt.Status).IsEqualTo(DeliveryStatus.Delivered);
await Assert.That(receipt.Destination).Contains("CreateOrder");

DeliveryReceipt Structure

IDeliveryReceipt Interface

public interface IDeliveryReceipt {
    MessageId MessageId { get; }              // Unique message identifier (value object)
    DateTimeOffset Timestamp { get; }         // When the message was accepted
    string Destination { get; }               // Where routed (receptor name, topic, etc.)
    DeliveryStatus Status { get; }            // Accepted, Queued, Delivered, Failed
    IReadOnlyDictionary<string, JsonElement> Metadata { get; }  // Extensible metadata
    CorrelationId? CorrelationId { get; }     // Correlation ID from message context
    MessageId? CausationId { get; }           // ID of the message that caused this one
    Guid? StreamId { get; }                   // Stream ID from [StreamId] attribute
}

public enum DeliveryStatus {
    Accepted = 0,   // Accepted by dispatcher, ready for processing
    Queued = 1,     // Queued for async processing (e.g., inbox pattern)
    Delivered = 2,  // Delivered to handler (handler executed)
    Failed = 3      // Failed to deliver or process
}

// Concrete implementation with factory methods
public sealed class DeliveryReceipt : IDeliveryReceipt {
    // Factory methods for creating receipts:
    public static DeliveryReceipt Accepted(MessageId messageId, string destination, ...);
    public static DeliveryReceipt Queued(MessageId messageId, string destination, ...);
    public static DeliveryReceipt Delivered(MessageId messageId, string destination, ...);
    public static DeliveryReceipt Failed(MessageId messageId, string destination, ...);
}

Use cases: - Long-running operations where you track completion separately - Commands that may be processed asynchronously - Remote command dispatch via transport (Azure Service Bus, etc.) - Idempotency tracking (store receipt, check for duplicates)

SendAsync Flow

graph TB
    subgraph Client["Client"]
        C1["dispatcher.SendAsync(command)"]
        C2["Envelope created (MessageId, CorrelationId)"]
        C3["Receptor invoked locally"]
        C4["Event stored in Outbox"]
        C5["DeliveryReceipt returned"]
        C1 --> C2 --> C3 --> C4 --> C5
    end

    subgraph Worker["Background Worker"]
        W1["Polls Outbox"]
        W2["Publishes event to transport (Azure Service Bus)"]
        W3["Marks message as Published"]
        W1 --> W2 --> W3
    end

    style C4 fill:#fff3cd,stroke:#ffc107
    style W1 fill:#fff3cd,stroke:#ffc107
    style W2 fill:#fff3cd,stroke:#ffc107

Key Points: - Asynchronous semantics: Receipt doesn't mean message is processed, just accepted - Outbox integration: Event stored for reliable delivery - Idempotency: Use MessageId to detect duplicates

Example: Long-Running Order Processing

Example: Long-Running Order Processing

[HttpPost("orders")]
public async Task<ActionResult> CreateOrder(
    [FromBody] CreateOrderRequest request,
    CancellationToken ct) {

    var command = new CreateOrder(
        CustomerId: request.CustomerId,
        Items: request.Items
    );

    // Send command - returns immediately with receipt
    var receipt = await _dispatcher.SendAsync(command);

    // Store receipt for later tracking
    await _trackingService.StoreReceiptAsync(
        receipt.MessageId,
        receipt.CorrelationId,
        "Order creation initiated"
    );

    // Return 202 Accepted with tracking URL
    return Accepted(new {
        trackingUrl = $"/api/orders/status/{receipt.CorrelationId}",
        messageId = receipt.MessageId
    });
}

[HttpGet("orders/status/{correlationId:guid}")]
public async Task<ActionResult> GetOrderStatus(Guid correlationId) {
    var status = await _trackingService.GetStatusAsync(correlationId);
    return Ok(status);
}

Pattern 2: LocalInvokeAsync - In-Process RPC

Use Case: Fast, synchronous-style command/query execution with typed response.

Signature: Pattern 2: LocalInvokeAsync - In-Process RPC

ValueTask<TResult> LocalInvokeAsync<TMessage, TResult>(
    TMessage message
) where TMessage : notnull;

Returns: Typed result from receptor (TResult).

Performance: < 20ns dispatch overhead, zero allocations (with object pooling).

Basic Usage

Basic Usage (2)

[HttpPost("orders")]
public async Task<ActionResult<OrderCreated>> CreateOrder(
    [FromBody] CreateOrderRequest request,
    CancellationToken ct) {

    var command = new CreateOrder(
        CustomerId: request.CustomerId,
        Items: request.Items
    );

    // Invoke receptor, get typed response
    var result = await _dispatcher.LocalInvokeAsync<CreateOrder, OrderCreated>(
        command
    );

    // Publish event to perspectives
    await _dispatcher.PublishAsync(result);

    return CreatedAtAction(
        nameof(GetOrder),
        new { orderId = result.OrderId },
        result
    );
}

LocalInvokeAsync Flow

graph TB
    subgraph Client["Client"]
        L1["dispatcher.LocalInvokeAsync&lt;CreateOrder, OrderCreated&gt;(command)"]
        L2["Lookup receptor in registry (compile-time, zero reflection)"]
        L3["Invoke receptor.HandleAsync(command)"]
        L4["Return typed response"]
        L5["&lt; 20ns overhead (zero allocations)"]
        L1 --> L2 --> L3 --> L4 --> L5
    end

    style L3 fill:#d4edda,stroke:#28a745

Key Points: - Compile-time safety: Type mismatch = compiler error - Zero reflection: Routing generated at compile time - Synchronous semantics: Waits for receptor to complete - Local only: Cannot cross process boundaries - Performance: Optimal for in-process commands/queries

Example: Query with Typed Response

Example: Query with Typed Response

public record GetOrderQuery(Guid OrderId);

public record OrderDetails(
    Guid OrderId,
    Guid CustomerId,
    OrderLineItem[] Items,
    decimal Total,
    string Status
);

public class GetOrderReceptor : IReceptor<GetOrderQuery, OrderDetails> {
    private readonly IOrderLens _lens;

    public GetOrderReceptor(IOrderLens lens) {
        _lens = lens;
    }

    public async ValueTask<OrderDetails> HandleAsync(
        GetOrderQuery query,
        CancellationToken ct = default) {

        var order = await _lens.GetOrderAsync(query.OrderId, ct);

        if (order is null) {
            throw new NotFoundException($"Order {query.OrderId} not found");
        }

        return new OrderDetails(
            OrderId: order.OrderId,
            CustomerId: order.CustomerId,
            Items: order.Items,
            Total: order.Total,
            Status: order.Status
        );
    }
}

// Controller usage
[HttpGet("orders/{orderId:guid}")]
public async Task<ActionResult<OrderDetails>> GetOrder(
    Guid orderId,
    CancellationToken ct) {

    var query = new GetOrderQuery(orderId);

    try {
        var details = await _dispatcher.LocalInvokeAsync<GetOrderQuery, OrderDetails>(
            query
        );

        return Ok(details);
    } catch (NotFoundException ex) {
        return NotFound(new { error = ex.Message });
    }
}

Type Safety Enforcement

Type Safety Enforcement

// ✅ CORRECT - Type mismatch caught at compile time
var result = await _dispatcher.LocalInvokeAsync<CreateOrder, OrderCreated>(command);

// ❌ COMPILER ERROR - Type mismatch
var wrong = await _dispatcher.LocalInvokeAsync<CreateOrder, PaymentProcessed>(command);
// Error: No receptor registered for CreateOrder → PaymentProcessed

Synchronous Receptor Invocation

New

LocalInvokeAsync supports both async (IReceptor) and sync (ISyncReceptor) receptors transparently:

Synchronous Receptor Invocation

// Async receptor - uses HandleAsync, returns ValueTask
public class AsyncOrderReceptor : IReceptor<CreateOrder, OrderCreated> {
    public async ValueTask<OrderCreated> HandleAsync(
        CreateOrder message,
        CancellationToken ct = default) {
        // Can use await
        await Task.Delay(1);
        return new OrderCreated(message.OrderId);
    }
}

// Sync receptor - uses Handle, returns directly
public class SyncOrderReceptor : ISyncReceptor<CreateOrder, OrderCreated> {
    public OrderCreated Handle(CreateOrder message) {
        // Pure computation, no await
        return new OrderCreated(message.OrderId);
    }
}

// Both invoked the same way
var result = await _dispatcher.LocalInvokeAsync<CreateOrder, OrderCreated>(command);

How it works: 1. Dispatcher first checks for async IReceptor<TMessage, TResponse> 2. If not found, checks for sync ISyncReceptor<TMessage, TResponse> 3. Sync receptors are invoked directly, result wrapped in pre-completed ValueTask 4. Auto-cascade works identically for both sync and async receptors

Performance benefit: Sync receptors avoid async state machine overhead entirely. The returned ValueTask is pre-completed, resulting in zero allocations.

Async Receptor Flow:
  LocalInvokeAsync → HandleAsync() → ValueTask (may allocate Task)

Sync Receptor Flow:
  LocalInvokeAsync → Handle() → new ValueTask(result) (pre-completed, zero alloc)

Precedence: If both IReceptor and ISyncReceptor exist for the same message type, the async IReceptor takes precedence to avoid breaking existing behavior.

See Receptors: ISyncReceptor Interface for when to use sync vs async receptors.

Performance Optimization

LocalInvokeAsync achieves < 20ns overhead through:

  1. Compile-time routing: Source generators create direct method calls
  2. Value types: Envelope and hops use structs where possible
  3. Object pooling: Reuse envelope instances
  4. Zero reflection: No runtime type discovery

Generated code example: Performance Optimization

// Generated by Whizbang.Generators
protected override ReceptorInvoker<TResult>? GetReceptorInvoker<TResult>(
    object message,
    Type messageType) {

    // Direct type check, no reflection
    if (messageType == typeof(CreateOrder)) {
        var receptor = _serviceProvider.GetRequiredService<IReceptor<CreateOrder, OrderCreated>>();
        return async msg => (TResult)(object)await receptor.HandleAsync((CreateOrder)msg);
    }

    // ... other message types

    return null;
}

// Sync receptor routing (fallback if no async receptor)
protected override SyncReceptorInvoker<TResult>? GetSyncReceptorInvoker<TResult>(
    object message,
    Type messageType) {

    if (messageType == typeof(CreateOrder)) {
        var receptor = _serviceProvider.GetService<ISyncReceptor<CreateOrder, OrderCreated>>();
        if (receptor == null) return null;
        return msg => (TResult)(object)receptor.Handle((CreateOrder)msg)!;
    }

    return null;
}

LocalInvokeAndSyncAsync - Invoke with Perspective Sync

Use Case: Invoke a handler and wait for ALL perspectives to process any events emitted during the invocation. This enables synchronous-feeling APIs over event-sourced systems.

Updated

The timeout-shaped overloads below (TimeSpan? timeout, onWaiting/onDecisionMade callbacks) are marked [Obsolete] as of the W4 dispatcher cleanup and will be removed in the next major. The replacement is the CancellationToken-only overload LocalInvokeAndSyncAsync<TMessage>(message, SyncMode, CancellationToken) — see SyncMode — Read-After-Write Dispatch. LocalInvokeAndSyncForPerspectiveAsync is not obsolete. The legacy overloads still function; migrate when convenient.

Signatures: LocalInvokeAndSyncAsync - Invoke with Perspective Sync

// With typed result - waits for ALL perspectives
Task<TResult> LocalInvokeAndSyncAsync<TMessage, TResult>(
    TMessage message,
    TimeSpan? timeout = null,
    Action<SyncWaitingContext>? onWaiting = null,
    Action<SyncDecisionContext>? onDecisionMade = null,
    CancellationToken cancellationToken = default
) where TMessage : notnull;

// Void (returns SyncResult) - waits for ALL perspectives
Task<SyncResult> LocalInvokeAndSyncAsync<TMessage>(
    TMessage message,
    TimeSpan? timeout = null,
    Action<SyncWaitingContext>? onWaiting = null,
    Action<SyncDecisionContext>? onDecisionMade = null,
    CancellationToken cancellationToken = default
) where TMessage : notnull;

// With typed result - waits for a SPECIFIC perspective only
Task<TResult> LocalInvokeAndSyncAsync<TMessage, TResult, TPerspective>(
    TMessage message,
    TimeSpan? timeout = null,
    Action<SyncWaitingContext>? onWaiting = null,
    Action<SyncDecisionContext>? onDecisionMade = null,
    CancellationToken cancellationToken = default
) where TMessage : notnull
  where TPerspective : class;

// Void - waits for a SPECIFIC perspective only
Task<SyncResult> LocalInvokeAndSyncForPerspectiveAsync<TMessage, TPerspective>(
    TMessage message,
    TimeSpan? timeout = null,
    Action<SyncWaitingContext>? onWaiting = null,
    Action<SyncDecisionContext>? onDecisionMade = null,
    CancellationToken cancellationToken = default
) where TMessage : notnull
  where TPerspective : class;

Callback Parameters: - onWaiting: Optional callback invoked when the sync wait begins. Only called if there are events to wait for and they have not already been processed. Not called for SyncOutcome.NoPendingEvents. - onDecisionMade: Optional callback always invoked when the sync decision is made, regardless of outcome.

Returns: The business result from the handler, after all perspectives have processed the events.

When to Use

Use LocalInvokeAndSyncAsync when:

  • You need to query read models immediately after a command
  • Building APIs that need immediate consistency
  • You want to return data that includes perspective-computed values

Basic Usage

Basic Usage - OrderMutation

public class OrderMutation {
    private readonly IDispatcher _dispatcher;

    public OrderMutation(IDispatcher dispatcher) {
        _dispatcher = dispatcher;
    }

    public async Task<OrderResult> CreateOrder(CreateOrderInput input) {
        var command = new CreateOrder(input.CustomerId, input.Items);

        // Invoke and wait for ALL perspectives to process events
        var result = await _dispatcher.LocalInvokeAndSyncAsync<CreateOrder, OrderResult>(
            command,
            timeout: TimeSpan.FromSeconds(10));

        // Safe to query read models now - they're fully updated
        return result;
    }
}

SyncResult Outcomes

When using the void overload, you get a SyncResult:

SyncResult Outcomes

var syncResult = await _dispatcher.LocalInvokeAndSyncAsync(command);

switch (syncResult.Outcome) {
    case SyncOutcome.Synced:
        // All perspectives processed successfully
        break;
    case SyncOutcome.TimedOut:
        // Handler completed but perspectives didn't finish in time
        break;
    case SyncOutcome.NoPendingEvents:
        // No events were emitted during the invocation
        break;
}

Timeout Handling

For the typed result overload, a TimeoutException is thrown if perspectives don't complete in time:

Timeout Handling

try {
    var result = await _dispatcher.LocalInvokeAndSyncAsync<CreateOrder, OrderResult>(
        command,
        timeout: TimeSpan.FromSeconds(5));
} catch (TimeoutException ex) {
    // Handler completed successfully, but perspectives timed out
    // Events were still emitted and will be processed eventually
}

The default timeout is 30 seconds if not specified.

How It Works

  1. Invokes the handler via LocalInvokeAsync
  2. Retrieves tracked events from IScopedEventTracker
  3. Waits for IEventCompletionAwaiter.WaitForEventsAsync() to complete
  4. Returns the result (or throws TimeoutException for the typed overload)

Perspective-Specific Sync

When you only need one specific read model to be updated before returning, use the perspective-specific overloads. This avoids waiting for all perspectives when you only depend on one:

Perspective-Specific Sync

// Wait for OrderSummaryPerspective only (typed result)
var result = await _dispatcher.LocalInvokeAndSyncAsync<CreateOrder, OrderResult, OrderSummaryPerspective>(
    command,
    timeout: TimeSpan.FromSeconds(5));
// OrderSummaryPerspective is guaranteed up-to-date; other perspectives may still be processing

// Void variant for a specific perspective
var syncResult = await _dispatcher.LocalInvokeAndSyncForPerspectiveAsync<CreateOrder, OrderSummaryPerspective>(
    command,
    timeout: TimeSpan.FromSeconds(5));

New

LocalInvokeAndSyncForPerspectiveAsync is named differently from the result-returning overload to avoid generic type parameter ambiguity between <TMessage, TResult> and <TMessage, TPerspective>.

Sync Callbacks

Use onWaiting and onDecisionMade callbacks for observability:

Sync Callbacks

var result = await _dispatcher.LocalInvokeAndSyncAsync<CreateOrder, OrderResult>(
    command,
    timeout: TimeSpan.FromSeconds(10),
    onWaiting: ctx => _logger.LogDebug("Waiting for {Count} events to sync...", ctx.EventCount),
    onDecisionMade: ctx => _logger.LogDebug("Sync decision: {Outcome}", ctx.Outcome));

Pattern 3: PublishAsync - Event Broadcasting

Use Case: Broadcast events to multiple listeners (perspectives).

Signature: Pattern 3: PublishAsync - Event Broadcasting

Task<IDeliveryReceipt> PublishAsync<TEvent>(
    TEvent eventData
);

Returns: IDeliveryReceipt with delivery status, correlation, and stream information.

Basic Usage

Basic Usage (4)

[HttpPost("orders")]
public async Task<ActionResult<OrderCreated>> CreateOrder(
    [FromBody] CreateOrderRequest request,
    CancellationToken ct) {

    var command = new CreateOrder(
        CustomerId: request.CustomerId,
        Items: request.Items
    );

    // 1. Execute command
    var result = await _dispatcher.LocalInvokeAsync<CreateOrder, OrderCreated>(
        command
    );

    // 2. Publish event to all perspectives
    await _dispatcher.PublishAsync(result);

    return CreatedAtAction(nameof(GetOrder), new { orderId = result.OrderId }, result);
}

PublishAsync Flow

graph TB
    subgraph Client["Client"]
        P1["dispatcher.PublishAsync(event)"]
        P2["Find all perspectives for event type"]
        P3["Invoke each perspective.UpdateAsync(event)"]
        P4["OrderSummaryPerspective.UpdateAsync(OrderCreated)"]
        P5["InventoryPerspective.UpdateAsync(OrderCreated)"]
        P6["AnalyticsPerspective.UpdateAsync(OrderCreated)"]
        P7["All perspectives updated (parallel execution)"]
        P1 --> P2 --> P3
        P3 --> P4 --> P7
        P3 --> P5 --> P7
        P3 --> P6 --> P7
    end

    style P4 fill:#cce5ff,stroke:#004085
    style P5 fill:#cce5ff,stroke:#004085
    style P6 fill:#cce5ff,stroke:#004085

Key Points: - Multiple listeners: One event triggers multiple perspectives - Fire-and-forget: Doesn't wait for perspectives to complete (async) - Local broadcast: All perspectives in current process - Outbox integration: Event can be stored for remote publishing

Example: Multiple Perspectives

Example: Multiple Perspectives

// Event
public record OrderCreated(
    Guid OrderId,
    Guid CustomerId,
    OrderLineItem[] Items,
    decimal Total,
    DateTimeOffset CreatedAt
);

// Perspective 1: Order summary for UI
public class OrderSummaryPerspective : IPerspectiveOf<OrderCreated> {
    private readonly IDbConnectionFactory _db;

    public async Task UpdateAsync(OrderCreated @event, CancellationToken ct = default) {
        await using var conn = _db.CreateConnection();
        await conn.ExecuteAsync(
            "INSERT INTO order_summaries (order_id, customer_id, total, status, created_at) VALUES (@OrderId, @CustomerId, @Total, @Status, @CreatedAt)",
            new {
                @event.OrderId,
                @event.CustomerId,
                @event.Total,
                Status = "Created",
                @event.CreatedAt
            }
        );
    }
}

// Perspective 2: Analytics/reporting
public class OrderAnalyticsPerspective : IPerspectiveOf<OrderCreated> {
    private readonly IDbConnectionFactory _db;

    public async Task UpdateAsync(OrderCreated @event, CancellationToken ct = default) {
        await using var conn = _db.CreateConnection();
        await conn.ExecuteAsync(
            "INSERT INTO order_analytics (order_id, customer_id, total, created_at) VALUES (@OrderId, @CustomerId, @Total, @CreatedAt)",
            new {
                @event.OrderId,
                @event.CustomerId,
                @event.Total,
                @event.CreatedAt
            }
        );
    }
}

// Perspective 3: Notification system
public class NotificationPerspective : IPerspectiveOf<OrderCreated> {
    private readonly IEmailService _email;

    public async Task UpdateAsync(OrderCreated @event, CancellationToken ct = default) {
        await _email.SendAsync(
            to: await GetCustomerEmailAsync(@event.CustomerId),
            subject: "Order Confirmed",
            body: $"Your order {@event.OrderId} has been created. Total: {@event.Total:C}"
        );
    }
}

When you call PublishAsync(orderCreated), all three perspectives are invoked automatically.

Remote Publishing with Outbox

Remote Publishing with Outbox

// In a receptor, you never write to the outbox by hand.
// Return the event (auto-cascade) — the framework serializes it to wh_outbox
// and a background worker publishes it to the transport.
public class CreateOrderReceptor : IReceptor<CreateOrder, (OrderResult, OrderCreated)> {
    public ValueTask<(OrderResult, OrderCreated)> HandleAsync(
        CreateOrder message,
        CancellationToken ct = default) {

        // Business logic...
        var result = new OrderResult(message.OrderId);
        var @event = new OrderCreated(/* ... */);

        // Default cascade mode routes through the outbox for cross-service delivery.
        // Use Route.Both(@event) to also invoke local receptors, or
        // Route.Local(@event) for local receptors + event store persistence only.
        return ValueTask.FromResult((result, @event));
    }
}

The outbox write itself happens inside the framework: the dispatcher serializes the cascaded event to wh_outbox (via IWorkCoordinator), and the outbox workers publish it to the transport. See Auto-Cascade to Outbox and Outbox Pattern.


Batch Operations

SendManyAsync

Use Case: Send multiple messages in a single batch, optimized with a single outbox scope and flush. Messages are processed both locally (if a receptor exists) and via the outbox (for cross-service delivery).

Breaking Change

Behavior Change (v0.9.10): SendManyAsync now routes messages to both local receptors and the outbox — matching PublishAsync semantics. Previously, messages with a local receptor were dispatched locally only, silently skipping outbox delivery. This caused events to not propagate cross-service when sent via SendManyAsync.

Signatures: SendManyAsync

// Generic (AOT-compatible, preserves type information)
Task<IEnumerable<IDeliveryReceipt>> SendManyAsync<TMessage>(
    IEnumerable<TMessage> messages) where TMessage : notnull;

// Non-generic (backward compatible)
Task<IEnumerable<IDeliveryReceipt>> SendManyAsync(
    IEnumerable<object> messages);

Returns: IDeliveryReceipt per message — Delivered for locally-handled messages, Accepted for outbox-only messages.

Example: SendManyAsync (2)

// Batch send commands — each gets local processing + outbox delivery
var commands = new[] {
    new UpdateInventory(productId1, 10),
    new UpdateInventory(productId2, -5),
    new UpdateInventory(productId3, 20)
};

var receipts = await _dispatcher.SendManyAsync(commands);
// All messages: local receptor invoked AND queued for cross-service delivery

Routing Behavior:

Message Has Local Receptor Has Outbox Strategy Behavior
Yes Yes Local + Outbox (receipt: Delivered)
Yes No Local only (receipt: Delivered)
No Yes Outbox only (receipt: Accepted)
No No Throws InvalidOperationException

Source: src/Whizbang.Core/Dispatcher.cs · Tests: tests/Whizbang.Core.Tests/Dispatcher/DispatcherOutboxTests.cs

PublishManyAsync

Use Case: Publish multiple events in a single batch with event topic routing. The batch equivalent of PublishAsync — events are broadcast to local handlers and queued for cross-service delivery via the outbox.

:::new{type="important"} v0.9.11: PublishManyAsync is the recommended API for batch event publishing. Unlike SendManyAsync, it is semantically explicit about publishing events. Both methods now correctly route events to event topics (not command destinations). :::

Signatures: Signatures

// Generic (AOT-compatible, preserves type information)
Task<IEnumerable<IDeliveryReceipt>> PublishManyAsync<TEvent>(
    IEnumerable<TEvent> events) where TEvent : notnull;

// Non-generic (backward compatible)
Task<IEnumerable<IDeliveryReceipt>> PublishManyAsync(
    IEnumerable<object> events);

Returns: IDeliveryReceipt per event — Delivered for locally-handled events, Accepted for outbox-only events.

Example: Example

// Batch publish events — single scope, single flush
var events = new[] {
    new OrderCreatedEvent(orderId1),
    new OrderCreatedEvent(orderId2),
    new OrderCreatedEvent(orderId3)
};

var receipts = await _dispatcher.PublishManyAsync(events);
// All events: local handlers invoked AND queued for cross-service delivery
// Events route to event topics (not command destinations)

Source: src/Whizbang.Core/Dispatcher.cs · Tests: tests/Whizbang.Core.Tests/Dispatcher/DispatcherOutboxTests.cs

LocalSendManyAsync

Use Case: Send multiple messages to local receptors only — no outbox delivery. Useful when you want batch local-only processing without cross-service propagation.

Signatures: LocalSendManyAsync

// Generic (AOT-compatible)
ValueTask<IEnumerable<IDeliveryReceipt>> LocalSendManyAsync<TMessage>(
    IEnumerable<TMessage> messages) where TMessage : notnull;

// Non-generic
ValueTask<IEnumerable<IDeliveryReceipt>> LocalSendManyAsync(
    IEnumerable<object> messages);

Returns: IDeliveryReceipt per message — all with Delivered status.

Throws: ReceptorNotFoundException if any message has no local receptor.

Example: LocalSendManyAsync (2)

// Process commands locally only — no outbox, no cross-service delivery
var commands = new[] {
    new ValidateOrder(orderId1),
    new ValidateOrder(orderId2)
};

var receipts = await _dispatcher.LocalSendManyAsync(commands);
// All receipts have Status == Delivered (processed in-process)
// No outbox messages created

API Design Intent:

Method Local Outbox Use Case
SendAsync / PublishAsync Yes Yes Default: full delivery
SendManyAsync Yes Yes Batch: full delivery (commands + events)
PublishManyAsync Yes Yes Batch: event publishing
LocalInvokeAsync Yes No In-process RPC
LocalSendManyAsync Yes No Batch: local only

Source: src/Whizbang.Core/Dispatcher.cs · Tests: tests/Whizbang.Core.Tests/Dispatcher/DispatcherOutboxTests.cs


LocalInvokeWithReceiptAsync - Invoke with Receipt

{verified: DispatcherInvokeWithReceiptTests.LocalInvokeWithReceipt_ReturnsBusinessResultAndReceiptAsync}

Use Case: Get both the typed business result AND a delivery receipt with dispatch metadata (MessageId, StreamId, CorrelationId, etc.) from a single in-process invocation. This bridges the gap between LocalInvokeAsync (typed result only) and SendAsync (receipt only).

InvokeResult<T>

InvokeResult Record

public sealed record InvokeResult<TResult>(
    TResult Value,           // The business result from the receptor
    IDeliveryReceipt Receipt // Delivery receipt with MessageId, StreamId, CorrelationId, etc.
);

Signatures

LocalInvokeWithReceiptAsync Signatures

// Generic (AOT-compatible) - preserves type at compile time
ValueTask<InvokeResult<TResult>> LocalInvokeWithReceiptAsync<TMessage, TResult>(
    TMessage message) where TMessage : notnull;

// Non-generic
ValueTask<InvokeResult<TResult>> LocalInvokeWithReceiptAsync<TResult>(
    object message);

// With explicit message context (AOT-compatible)
ValueTask<InvokeResult<TResult>> LocalInvokeWithReceiptAsync<TMessage, TResult>(
    TMessage message,
    IMessageContext context) where TMessage : notnull;

// With dispatch options (cancellation, timeout)
ValueTask<InvokeResult<TResult>> LocalInvokeWithReceiptAsync<TResult>(
    object message, DispatchOptions options);

Basic Usage

LocalInvokeWithReceiptAsync Usage

[HttpPost("orders")]
public async Task<ActionResult> CreateOrder(
    [FromBody] CreateOrderRequest request,
    CancellationToken ct) {

    var command = new CreateOrder(request.CustomerId, request.Items);

    // Get both the business result AND delivery metadata
    var invokeResult = await _dispatcher
        .LocalInvokeWithReceiptAsync<CreateOrder, OrderCreated>(command);

    // Access the typed business result
    var order = invokeResult.Value;

    // Access delivery metadata for tracking
    var receipt = invokeResult.Receipt;

    return CreatedAtAction(
        nameof(GetOrder),
        new { orderId = order.OrderId },
        new {
            order,
            messageId = receipt.MessageId,
            correlationId = receipt.CorrelationId,
            streamId = receipt.StreamId
        });
}

When to use: - API endpoints that need to return both business data and tracking metadata - Correlation tracking where you need the MessageId alongside the result - Scenarios requiring both typed response and stream/correlation IDs

Performance note: LocalInvokeWithReceiptAsync always takes the tracing code path (creates an envelope) since the receipt requires dispatch metadata. If you do not need the receipt, prefer LocalInvokeAsync for lower overhead.

Source: src/Whizbang.Core/IDispatcher.cs · Tests: tests/Whizbang.Core.Tests/Dispatcher/DispatcherInvokeWithReceiptTests.cs


Error Handling

LocalInvokeAsync Error Handling

LocalInvokeAsync Error Handling

[HttpPost("orders")]
public async Task<ActionResult> CreateOrder(
    [FromBody] CreateOrderRequest request,
    CancellationToken ct) {

    try {
        var command = new CreateOrder(request.CustomerId, request.Items);

        var result = await _dispatcher.LocalInvokeAsync<CreateOrder, OrderCreated>(
            command
        );

        await _dispatcher.PublishAsync(result);

        return CreatedAtAction(nameof(GetOrder), new { orderId = result.OrderId }, result);

    } catch (ValidationException ex) {
        // Business rule violation (e.g., invalid quantity)
        return BadRequest(new { error = ex.Message, errors = ex.ValidationErrors });

    } catch (NotFoundException ex) {
        // Entity not found (e.g., customer doesn't exist)
        return NotFound(new { error = ex.Message });

    } catch (InvalidOperationException ex) {
        // Business logic error (e.g., insufficient inventory)
        return Conflict(new { error = ex.Message });

    } catch (OperationCanceledException) {
        // Client cancelled request
        return StatusCode(499, new { error = "Request cancelled" });

    } catch (Exception ex) {
        // Unexpected error
        _logger.LogError(ex, "Failed to create order");
        return StatusCode(500, new { error = "An unexpected error occurred" });
    }
}

SendAsync Error Handling

SendAsync Error Handling

try {
    var receipt = await _dispatcher.SendAsync(command);

    // Store receipt for tracking
    await _trackingService.StoreAsync(receipt);

    return Accepted(new { trackingId = receipt.CorrelationId });

} catch (Exception ex) {
    // SendAsync errors typically indicate infrastructure issues
    _logger.LogError(ex, "Failed to dispatch command");
    return StatusCode(503, new { error = "Service temporarily unavailable" });
}

PublishAsync Error Handling

PublishAsync Error Handling

try {
    await _dispatcher.PublishAsync(orderCreated);

} catch (AggregateException ex) {
    // One or more perspectives failed
    foreach (var inner in ex.InnerExceptions) {
        _logger.LogError(inner, "Perspective update failed");
    }

    // Decide: fail request or continue?
    // Option 1: Fail entire request
    throw;

    // Option 2: Log and continue (eventual consistency)
    // Perspectives will catch up via event replay
}

Advanced Patterns

Pattern: Command + Event in Single Transaction

Pattern: Command + Event in Single Transaction

[HttpPost("orders")]
public async Task<ActionResult<OrderCreated>> CreateOrder(
    [FromBody] CreateOrderRequest request,
    CancellationToken ct) {

    var command = new CreateOrder(request.CustomerId, request.Items);

    // Execute command
    var result = await _dispatcher.LocalInvokeAsync<CreateOrder, OrderCreated>(
        command
    );

    // Publish event to local perspectives
    await _dispatcher.PublishAsync(result);

    // Also send via SendAsync for outbox (remote publishing)
    await _dispatcher.SendAsync(result);

    return CreatedAtAction(nameof(GetOrder), new { orderId = result.OrderId }, result);
}

Result: - Local perspectives updated immediately - Event stored in outbox for remote publishing - Background worker publishes to Azure Service Bus

Pattern: Conditional Publishing

Pattern: Conditional Publishing

public async Task<ActionResult> ProcessPayment(
    [FromBody] ProcessPaymentRequest request,
    CancellationToken ct) {

    var command = new ProcessPayment(request.OrderId, request.Amount);

    var result = await _dispatcher.LocalInvokeAsync<ProcessPayment, PaymentResult>(
        command
    );

    // Publish different events based on result
    if (result.IsSuccess) {
        await _dispatcher.PublishAsync(
            new PaymentProcessed(result.OrderId, result.Amount, result.TransactionId));
    } else {
        await _dispatcher.PublishAsync(
            new PaymentFailed(result.OrderId, result.Amount, result.ErrorCode));
    }

    return Ok(result);
}

Pattern: Batch Processing

Pattern: Batch Processing

public async Task<ActionResult> ProcessOrders(
    [FromBody] ProcessOrdersRequest request,
    CancellationToken ct) {

    var results = new List<OrderCreated>();

    foreach (var item in request.Orders) {
        var command = new CreateOrder(item.CustomerId, item.Items);

        var result = await _dispatcher.LocalInvokeAsync<CreateOrder, OrderCreated>(
            command
        );

        results.Add(result);
    }

    // Publish all events in batch
    foreach (var result in results) {
        await _dispatcher.PublishAsync(result);
    }

    return Ok(new { ordersCreated = results.Count, orders = results });
}