Skip to content

Fan-Out

Fan-Out (or Scatter) transforms a single message into N independent messages, each processed in parallel. In a messaging context, the handler receives a trigger and returns a collection of commands — the bus publishes each command individually within the same Outbox transaction. Granit uses this pattern in notifications (one trigger to N deliveries per recipient x channel) and webhooks (one event to N sends per active subscription).

flowchart LR
    T[Trigger] --> H[FanoutHandler]
    H --> C1[Command 1]
    H --> C2[Command 2]
    H --> C3[Command N]
    C1 --> W1[Worker 1]
    C2 --> W2[Worker 2]
    C3 --> W3[Worker N]
sequenceDiagram
    participant App
    participant Fan as NotificationFanoutHandler
    participant Sub as SubscriptionReader
    participant Bus as Wolverine Outbox

    App->>Bus: NotificationTrigger
    Bus->>Fan: HandleAsync(trigger)
    Fan->>Sub: Resolve recipients
    Sub-->>Fan: [User A, User B, User C]
    Fan-->>Bus: IEnumerable DeliverNotificationCommand
    Bus->>Bus: Publish Command A (Email)
    Bus->>Bus: Publish Command B (Push)
    Bus->>Bus: Publish Command C (Email + SMS)

Granit implements Fan-Out in 2 distinct modules, leveraging the Wolverine Task<IEnumerable<TCommand>> convention: each returned element is published as an independent message within the same Outbox transaction.

1. NotificationFanoutHandler — multi-channel notifications

Section titled “1. NotificationFanoutHandler — multi-channel notifications”

Transforms a NotificationTrigger into N DeliverNotificationCommand (one per recipient x preferred channel).

ElementDetail
ClassNotificationFanoutHandler
PackageGranit.Notifications
InputNotificationTrigger
OutputIEnumerable<DeliverNotificationCommand>
ResolutionExplicit > Followers > Subscribers (decreasing priority)
public async Task<IEnumerable<DeliverNotificationCommand>> HandleAsync(
NotificationTrigger trigger,
CancellationToken cancellationToken)
{
// 1. Resolve recipients (explicit > followers > subscribers)
// 2. Load channel preferences per user
// 3. Create a DeliverNotificationCommand per recipient x channel
// Return empty if no recipients -- no exception
}

Channel resolution logic: each recipient can have preferences (opt-out by channel). The NotificationDefinition provides default channels. The handler filters out disabled channels before creating commands.

2. WebhookFanoutHandler — webhooks per subscription

Section titled “2. WebhookFanoutHandler — webhooks per subscription”

Transforms a WebhookTrigger into N SendWebhookCommand (one per active subscription for the event type).

ElementDetail
ClassWebhookFanoutHandler
PackageGranit.Webhooks
InputWebhookTrigger
OutputIEnumerable<SendWebhookCommand>
FilteringActive subscriptions for EventType
public async Task<IEnumerable<SendWebhookCommand>> HandleAsync(
WebhookTrigger trigger,
CancellationToken cancellationToken)
{
// 1. Query active subscriptions for trigger.EventType
// 2. Create a standardized WebhookEnvelope (metadata + payload)
// 3. Create a SendWebhookCommand per subscription
// Return empty if no subscriptions -- no exception
}

Each command receives a distinct DeliveryId (separate from the shared EventId), enabling individual tracking and isolated retry.

PropertyDetail
Wolverine conventionTask<IEnumerable<T>> — automatic cascade into the Outbox
Transactional guaranteeAll commands published in the same Outbox transaction
Empty caseReturns Enumerable.Empty<T>() — no exception
IdempotencyUnique DeliveryId per command for audit and retry
Multi-tenancyAmbient ICurrentTenant context with fallback to embedded TenantId
ObservabilityOpenTelemetry Activity with fan-out cardinality
FileRole
src/Granit.Notifications/Handlers/NotificationFanoutHandler.csNotification fan-out
src/Granit.Notifications/Messages/NotificationTrigger.csTrigger message
src/Granit.Notifications/Messages/DeliverNotificationCommand.csDelivery command
src/Granit.Webhooks/Handlers/WebhookFanoutHandler.csWebhook fan-out
src/Granit.Webhooks/Messages/WebhookTrigger.csTrigger message
src/Granit.Webhooks/Messages/SendWebhookCommand.csSend command
ProblemSolution
Notification to 100 recipients blocks the handlerFan-out into 100 independent commands, processed in parallel
One webhook failure must not block the othersEach SendWebhookCommand has its own isolated retry
Message loss if crash during fan-outWolverine Outbox — atomic commit of all commands
Recipient opts out of a channelFiltering by preferences before creating commands
Audit trail per deliveryUnique DeliveryId per command (distinct from EventId)
// --- Trigger a notification (automatic fan-out) ---
await messageBus.PublishAsync(
new NotificationTrigger(
NotificationId: guidGenerator.Create(),
NotificationTypeName: "appointment.reminder",
Data: JsonSerializer.SerializeToElement(new { PatientName = "Dupont" }),
RecipientUserIds: [doctorId, secretaryId]),
cancellationToken).ConfigureAwait(false);
// The NotificationFanoutHandler resolves preferred channels for each recipient
// and publishes a DeliverNotificationCommand per recipient x channel.
// --- Trigger a webhook (automatic fan-out) ---
await messageBus.PublishAsync(
new WebhookTrigger(
EventId: guidGenerator.Create(),
EventType: "invoice.paid",
Payload: JsonSerializer.SerializeToElement(invoiceDto),
OccurredAt: timeProvider.GetUtcNow()),
cancellationToken).ConfigureAwait(false);
// The WebhookFanoutHandler creates a SendWebhookCommand per active subscription
// for "invoice.paid". Each delivery is signed and sent independently.