6. Runtime Flow
This chapter traces one notification from the moment an ERP business service decides to send it, through every stage, to final delivery (or dead letter).
6.1 End-to-end sequence
6.2 Stage-by-stage notes
- Trigger — any ERP business service (e.g.
PurchaseInvoiceServiceon approval) builds aDispatchNotificationRequest { EventKey, RecipientUserId, Data, RefType, RefId }and calls the ERP'sNotificationDispatchService.DispatchAsync(...). This is a thin, ~57-line pass-through to the Framework's own dispatch service — the ERP host does not re-implement dispatch logic. - Pipeline (event configuration) — the framework loads
NotificationEventConfigurationbyEventKey. If no row exists, all channels are enabled (safe backward-compatible default). If a row exists, eachEnable{Channel}flag gates whether that channel is attempted at all. - Template selection — for each enabled channel, the framework loads all
AppNotificationTemplatecandidates forEventKey+Channel, then picks the best match: language-specific first (LanguageCode== resolved language), falling back to a template withLanguageCode == null(applies to all languages). If no template exists for a channel, that channel is silently skipped for this event. - Rendering —
ScribanTemplateRendererrendersSubjectTemplate/BodyTemplateagainstrequest.Data, using camelCase member names ({{ customerName }}, not{{ customer_name }}) via amember => member.Namerenamer on both the imported object and the template context. - Dispatch — for Email/SMS/Push/SignalR/InApp, the resolved
INotificationChannelProvideris invoked with aChannelDispatchContext(rendered subject/body, recipient contact info, tenant ID, WhatsApp fields left null for non-WhatsApp channels). For WhatsApp,WhatsAppNotificationSenderis invoked directly (not through the provider resolver) — see §7.3 for its own internal guard/fallback chain. - Channel — each provider performs the actual send (SMTP, SMS gateway, Firebase, SignalR group send, or
an
AppNotificationrow insert for InApp) and returns aChannelDeliveryResult(Succeeded, optionalErrorCode/ErrorMessage,FailureType, timing). - History — every attempt, success or failure, is recorded as an append-only
NotificationDeliveryAttemptrow (never updated or deleted — only more attempts are added). - Retry — on failure, the matching
NotificationDeliveryPolicyfor that channel decides retryability (RetryOnTimeout,RetryOnRateLimit,RetryOnNetworkFailure,RetryOnTemporaryFailure,RetryOnPermanentFailure) and retry strategy/timing. If retryable and underMaxRetries, aNotificationRetryQueuerow is scheduled (State = RetryScheduled) for a Hangfire pipeline job to pick up later — see Chapter 12. - Dead Letter — if not retryable, or retries are exhausted, and
DeliveryPolicy.DeadLetterEnabledis true, aNotificationDeadLetterrow is created (Status = Pending) for manual operator review/requeue. - SignalR — for the SignalR and InApp channels specifically, delivery to a connected browser session
happens via
Clients.User(userId).SendAsync("messages", payload)on the legacyNotificationHubin MultiTenancyApi. A user with no active WebSocket connection simply does not receive the real-time push — theAppNotificationrow (InApp channel) is what makes the message durable and visible on next login viaGetInbox. See Chapter 10 — SignalR. - Delivery Receipts (inbound, optional) — for channels where the provider reports delivery status
asynchronously (currently WhatsApp via Meta webhooks), a later inbound webhook call updates
NotificationDeliveryReceiptindependently of the synchronous dispatch flow above — see §9.11.
6.3 What never changes mid-flow
- A
NotificationDeliveryAttemptis never edited after creation — corrections happen only via a new attempt row (e.g. a retry) or a dead-letter/receipt record, mirroring the platform's append-only philosophy for audit-relevant tables (seedocs/BACKEND_BUSINESS_RULES.mdfor the analogous rule onStockItemTransaction). - A failure in one channel never blocks dispatch to another channel for the same event — each channel's provider call is isolated with its own try/catch at the dispatch-loop level.
- WhatsApp dispatch specifically never rethrows — a WhatsApp provider outage must not fail the caller's business transaction (e.g. approving a purchase invoice must succeed even if the WhatsApp confirmation message fails to send).