Skip to content
bitzorcas
中EN

Reference

Multi-Channel Message Delivery: From Facts to Fanout

Explore the BitzOrcas.Modern message delivery engine. Learn how notification persistence, CAP transactional outbox, and email/SMS/IM connectors decouple external I/O.

Last updated

In legacy architectures, developers frequently call external email or SMS SDKs directly inside the main database transaction:

// Anti-pattern: Synchronous network call inside business transaction
await db.SaveChangesAsync();
await emailSdk.SendAsync(...); // If external gateway times out for 10s, the caller hangs!

This introduces fatal operational hazards:

  1. Network Latency Monopolizes DB Connections: External network delays hold database transaction locks;
  2. State Inconsistency: If email succeeds but the transaction rolls back, customers receive confirmation emails for aborted orders!

BitzOrcas.Modern uses “Notification Persistence First + CAP Transactional Outbox Asynchronous Fanout”: Business handlers simply persist in-app notification records, while asynchronous workers fan out delivery to external email, SMS, and enterprise IM connectors.

Multi-Channel Delivery Lifecycle

1. Business Use Case (e.g. User Registration)

2. NotificationService (Persist In-App Notification)

3. CAP Transactional Outbox (notification.created)

4. NotificationCreatedConsumer (Async Worker)

5. INotificationOrchestrator (Resolves User Preferences)

6. IEmailDeliveryPort (Email Connector)

7. ISmsDeliveryPort (SMS Connector)

8. IInstantMessageDeliveryPort (Teams / Slack / DingTalk)


Step 1: Publishing Notification Facts

Business logic remains decoupled from delivery channels by persisting notifications:

Send a business notification via NotificationService.ComposeAsync
using BitzOrcas.Domain.Results;
// The platform's single exit is NotificationService.ComposeAsync: one call writes
// the inbox fact and feeds channel orchestration. There is no
// INotificationAppService / CreateNotificationRequest.
public sealed class LegalContractSignedHandler(NotificationService notifications)
{
public async ValueTask<Result> Handle(LegalContractSigned command, CancellationToken ct)
{
var created = await notifications.ComposeAsync(
tenantId: command.TenantId,
userId: command.SignerUserId,
code: "contract.contract.signed",
title: "Contract signed",
body: $"Contract {command.ContractNo} has been signed; download your copy.",
type: NotificationType.Business,
category: command.BusinessType,
severity: NotificationSeverity.Normal,
linkUrl: null,
linkText: null,
metadataJson: metadataJson,
cancellationToken: ct);
return created.IsSuccess ? Result.Success() : Result.Failure(created.Error);
}
}

Step 2: Asynchronous Fanout and Preference Routing

Workers resolve channel preferences and deliver asynchronously:

NotificationCreatedConsumer.cs: Asynchronous Delivery
public sealed class NotificationCreatedConsumer(
INotificationOrchestrator orchestrator,
IEmailDeliveryPort emailPort,
ISmsDeliveryPort smsPort)
{
public async Task ProcessAsync(NotificationCreatedIntegrationEvent @event, CancellationToken ct)
{
// 1. Resolve user channel preferences
var channels = await orchestrator.ResolveChannelsAsync(
new NotificationContext(tenantId, userId, code, severity, category, metadataJson),
ct);
// 2. Deliver in parallel (channel failures do not affect others)
if (channels.Contains("Email"))
{
// EmailDeliveryRequest has six required parameters
// (NotificationId/TenantId/RecipientUserId/Subject/TextBody/Channel); HtmlBody is optional.
await emailPort.SendAsync(new EmailDeliveryRequest(
To: @event.EmailAddress,
Subject: @event.Title,
HtmlBody: @event.Content), ct);
}
}
}

Summary

Decoupled messaging guarantees platform resilience:

  • Zero Transaction Blocking: In-app notifications commit in milliseconds;
  • Channel Fault Isolation: Email gateway outages never abort core operations;
  • Extensible Adapters: Adding Slack or Telegram connectors requires zero domain changes.

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%