Skip to content

Concepts & Architecture

Pragmatic.Notifications is a channel-agnostic notification pipeline. The caller constructs a NotificationRequest describing what to send and to whom; the pipeline decides how to deliver it.

The design separates three concerns:

  1. Recipient resolution — translating logical targets (user ID, role, tenant) into concrete addresses
  2. Channel routing — selecting delivery channels based on priority, preferences, and registered providers
  3. Delivery tracking — persisting every delivery attempt with lifecycle status
NotificationRequest
|
1. Validate Checks Subject and Body are non-empty
|
2. Resolve IRecipientResolver: logical recipient -> ResolvedRecipient[]
|
3. Route INotificationRouter: priority + preferences -> NotificationChannel flags
|
4. Deliver INotificationChannel.DeliverAsync per channel per recipient
|
5. Track INotificationStore: create record, update status (Sent/Failed)
|
NotificationResult

Minimal validation: Subject and Body must be non-empty. If validation fails, the pipeline returns NotificationResult.Failed(...) immediately. No records are created.

IRecipientResolver.ResolveAsync converts a NotificationRecipient into one or more ResolvedRecipient records. Each resolved recipient has:

  • Address — concrete delivery target (email, URL, phone number)
  • Channel — the channel inferred from the address type
  • Locale — for future localized content
  • TenantId — for multi-tenant routing
  • Preferences — user notification preferences (if available)

The DefaultRecipientResolver handles direct addresses (email, phone, webhook). For user ID, role, or tenant resolution, register a custom IRecipientResolver that queries your identity system.

INotificationRouter.Route determines which channels to use for each resolved recipient. The DefaultNotificationRouter applies priority-based logic:

PriorityStrategy
CriticalCombine all registered channels (OR all flags)
HighEmail + Push (if registered), fallback to first registered
NormalRecipient’s resolved channel, or email fallback
LowEmail only (digest candidate), or first registered

Before routing, preferences are checked:

  • If Preferences.Enabled is false, returns NotificationChannel.None (skip)
  • If the Category is in Preferences.MutedCategories, returns NotificationChannel.None

The ChannelOverride property on NotificationRequest bypasses routing entirely.

For each resolved recipient, the pipeline iterates over individual channel flags and calls INotificationChannelFactory.GetChannel(flag, tenantId). If a channel provider is registered, it calls INotificationChannel.DeliverAsync.

Each delivery attempt returns a DeliveryResult(Success, ProviderId, ErrorMessage). The pipeline collects errors and delivery IDs.

Partial success: if some deliveries succeed and some fail, the result is still Success = true with the errors attached. Only when all deliveries fail does the result become Success = false.

Before each delivery attempt, a NotificationRecord with Status = Pending is created via INotificationStore.CreateAsync. After delivery, the status is updated to Sent or Failed.

The record includes: audience, recipient address, channel, subject, category, tenant ID, metadata, and lifecycle timestamps (CreatedAt, SentAt, DeliveredAt, ReadAt).

The NotificationAudience enum categorizes the intent of a notification:

AudienceDescriptionTypical Channel
EndUserApplication customer (hotel guest, subscriber)Email, Push, SMS
AdminPlatform operator, ops teamEmail, Slack
DeveloperDevOps, CI/CD systemsWebhook, Slack
TenantAdminMulti-tenant administratorEmail, InApp
SystemMonitoring, health checksWebhook

The audience affects recipient resolution (the resolver may query different stores for different audiences) and can inform custom routing logic.

NotificationChannel is a [Flags] enum with six values:

[Flags]
public enum NotificationChannel
{
None = 0,
Email = 1 << 0, // SMTP via Pragmatic.Email
Sms = 1 << 1, // SMS provider
Push = 1 << 2, // Push notification
InApp = 1 << 3, // In-application notification center
Webhook = 1 << 4, // HTTP POST
Slack = 1 << 5, // Slack webhook
}

Because it is a flags enum, routing can return Email | Push for High priority, and the pipeline will deliver to both channels independently.

SmtpChannel (Pragmatic.Notifications): Delegates to Pragmatic.Email’s IEmailSender for SMTP delivery, connection pooling, DKIM, and middleware. Configuration splits sender identity (SmtpOptions) from transport settings (SmtpTransportOptions).

WebhookChannel (Pragmatic.Notifications.Webhook): Sends an HTTP POST with JSON payload. Uses a named HttpClient (Pragmatic.Notifications.Webhook). If NotificationContent.JsonPayload is set, it sends the raw payload; otherwise it serializes { Subject, Body, Timestamp } using AOT-safe JsonSerializerContext.

Implement INotificationChannel and register via builder.AddChannel<T>():

public sealed class PushChannel : INotificationChannel
{
public NotificationChannel Channel => NotificationChannel.Push;
public async Task<DeliveryResult> DeliverAsync(
ResolvedRecipient recipient, NotificationContent content, CancellationToken ct)
{
// Deliver to push service
return DeliveryResult.Succeeded(pushMessageId);
}
}
Pending --> Sent --> Delivered --> Read
| |
+---> Failed Bounced
StatusMeaning
PendingRecord created, delivery not yet attempted
SentChannel provider accepted the message
DeliveredConfirmed delivery (webhook: HTTP 2xx; email: accepted by relay)
ReadRecipient opened/read the notification (requires read-tracking integration)
FailedDelivery attempt failed (error message stored)
BouncedEmail bounce or permanent delivery failure

EnqueueAsync writes to a BoundedChannel<NotificationRequest> (default capacity: 1000). A BackgroundService (NotificationDeliveryWorker) reads from this channel and processes through the same pipeline. This provides non-blocking fire-and-forget semantics with backpressure.

// Returns immediately with a tracking ID
var result = await notificationService.EnqueueAsync(request);
// result.NotificationId is available for polling

The worker catches exceptions per-request and logs them without crashing the background loop.

The INotificationChannelFactory.GetChannel(channel, tenantId) signature supports per-tenant channel resolution. The DefaultChannelFactory ignores tenantId, but a custom factory can return different SMTP configurations per tenant.

NotificationRecord.TenantId is indexed in the EF Core store for efficient per-tenant queries.

NotificationPreferences controls per-user delivery behavior:

  • Enabled — global kill switch; if false, no notifications are delivered
  • PreferredChannel — used when priority allows choice (Normal)
  • MutedCategories — set of category strings the user has opted out of
  • DoNotDisturb — holds non-critical notifications (reserved for future digest support)
  • Locale — for future localized template rendering

Implement INotificationPreferenceProvider to source preferences from your user profile database. The DefaultPreferenceProvider returns all-enabled defaults.