Skip to main content
Version: Next

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

  1. Trigger — any ERP business service (e.g. PurchaseInvoiceService on approval) builds a DispatchNotificationRequest { EventKey, RecipientUserId, Data, RefType, RefId } and calls the ERP's NotificationDispatchService.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.
  2. Pipeline (event configuration) — the framework loads NotificationEventConfiguration by EventKey. If no row exists, all channels are enabled (safe backward-compatible default). If a row exists, each Enable{Channel} flag gates whether that channel is attempted at all.
  3. Template selection — for each enabled channel, the framework loads all AppNotificationTemplate candidates for EventKey + Channel, then picks the best match: language-specific first (LanguageCode == resolved language), falling back to a template with LanguageCode == null (applies to all languages). If no template exists for a channel, that channel is silently skipped for this event.
  4. RenderingScribanTemplateRenderer renders SubjectTemplate/BodyTemplate against request.Data, using camelCase member names ({{ customerName }}, not {{ customer_name }}) via a member => member.Name renamer on both the imported object and the template context.
  5. Dispatch — for Email/SMS/Push/SignalR/InApp, the resolved INotificationChannelProvider is invoked with a ChannelDispatchContext (rendered subject/body, recipient contact info, tenant ID, WhatsApp fields left null for non-WhatsApp channels). For WhatsApp, WhatsAppNotificationSender is invoked directly (not through the provider resolver) — see §7.3 for its own internal guard/fallback chain.
  6. Channel — each provider performs the actual send (SMTP, SMS gateway, Firebase, SignalR group send, or an AppNotification row insert for InApp) and returns a ChannelDeliveryResult (Succeeded, optional ErrorCode/ErrorMessage, FailureType, timing).
  7. History — every attempt, success or failure, is recorded as an append-only NotificationDeliveryAttempt row (never updated or deleted — only more attempts are added).
  8. Retry — on failure, the matching NotificationDeliveryPolicy for that channel decides retryability (RetryOnTimeout, RetryOnRateLimit, RetryOnNetworkFailure, RetryOnTemporaryFailure, RetryOnPermanentFailure) and retry strategy/timing. If retryable and under MaxRetries, a NotificationRetryQueue row is scheduled (State = RetryScheduled) for a Hangfire pipeline job to pick up later — see Chapter 12.
  9. Dead Letter — if not retryable, or retries are exhausted, and DeliveryPolicy.DeadLetterEnabled is true, a NotificationDeadLetter row is created (Status = Pending) for manual operator review/requeue.
  10. SignalR — for the SignalR and InApp channels specifically, delivery to a connected browser session happens via Clients.User(userId).SendAsync("messages", payload) on the legacy NotificationHub in MultiTenancyApi. A user with no active WebSocket connection simply does not receive the real-time push — the AppNotification row (InApp channel) is what makes the message durable and visible on next login via GetInbox. See Chapter 10 — SignalR.
  11. Delivery Receipts (inbound, optional) — for channels where the provider reports delivery status asynchronously (currently WhatsApp via Meta webhooks), a later inbound webhook call updates NotificationDeliveryReceipt independently of the synchronous dispatch flow above — see §9.11.

6.3 What never changes mid-flow

  • A NotificationDeliveryAttempt is 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 (see docs/BACKEND_BUSINESS_RULES.md for the analogous rule on StockItemTransaction).
  • 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).