Skip to main content
Version: 1.2

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).