Skip to main content

Domain Model

:::info Source Sourced from services/notification-service/DOMAIN_MODEL.md in the documentation repo. :::

Ubiquitous Language

TermDefinition
NotificationA single message-delivery intent for one recipient on one channel.
TemplateA versioned, localized content skeleton identified by a template key.
PreferenceA user's per-category, per-channel consent and quiet-hour configuration.
DigestA collapsed group of related notifications delivered as one message.
Suppression ListRegistry of addresses marked undeliverable (hard bounces, complaints).
Delivery ReceiptProvider-returned confirmation that the message reached the recipient's infrastructure.
BounceProvider-returned rejection; soft (retryable) or hard (permanent).
Webhook SubscriptionCustomer-registered HTTPS endpoint subscribing to published events.
Template KeyDotted namespace {service}.{event}.{channel} identifying a template.

Aggregates

1. Notification (root)

interface Notification {
id: ULID;
tenantId: TenantId;
userId: UserId; // recipient
templateKey: string; // "enrollment.created.email"
templateVersion: SemVer; // "1.3.0" - snapshot used
channel: 'email' | 'sms' | 'push' | 'inapp' | 'webhook';
recipientAddress: RecipientAddress; // resolved address VO
variables: Record<string, JSONValue>; // template inputs
locale: Locale; // ICU locale tag, e.g., "en-KE"
status: NotificationStatus;
category: NotificationCategory;
priority: 'low' | 'normal' | 'high' | 'critical';
scheduledFor?: ISODate; // future-dated send
correlationId: string; // trace + idempotency
sourceEvent?: { type: string; id: string };// originating domain event
digestGroupId?: ULID; // if rolled into a digest
attempts: DeliveryAttempt[];
suppressionReason?: SuppressionReason; // if status = suppressed
deliveredAt?: ISODate;
readAt?: ISODate; // in-app only
createdAt: ISODate;
updatedAt: ISODate;
expiresAt: ISODate; // TTL for body retention
}

type NotificationStatus =
| 'queued' // accepted, awaiting render
| 'rendering' // template resolved, body being built
| 'sending' // handed to provider
| 'sent' // provider accepted
| 'delivered' // provider confirmed delivery
| 'opened' // (email only) open pixel fired
| 'clicked' // (email only) tracked link clicked
| 'failed' // terminal failure
| 'bounced' // provider returned bounce
| 'suppressed' // blocked by preference/suppression list
| 'superseded'; // rolled into later digest

type NotificationCategory =
| 'academic'
| 'billing'
| 'marketing'
| 'security'
| 'social'
| 'system'
| 'compliance';

type SuppressionReason =
| 'user_opted_out'
| 'quiet_hours'
| 'hard_bounce_address'
| 'complaint_registered'
| 'rate_limit_exceeded'
| 'budget_exhausted'
| 'invalid_address'
| 'minor_guardian_routing';

interface DeliveryAttempt {
attemptNumber: number;
providerName: string; // "ses", "twilio", "fcm"
providerMessageId?: string;
requestedAt: ISODate;
respondedAt?: ISODate;
outcome: 'accepted' | 'rejected' | 'timeout' | 'bounced' | 'complained';
errorCode?: string;
errorMessage?: string; // redacted, safe to log
latencyMs?: number;
}

Invariants:

  • tenantId is immutable after creation.
  • status transitions follow a defined state machine (see below).
  • A notification with channel = 'email' must have recipientAddress.email present.
  • A notification with channel = 'inapp' does NOT consult provider; status goes queued → sent → delivered on WebSocket ack or next poll.
  • attempts.length <= 6 unless channel is inapp (no retries).
  • If scheduledFor > now(), status stays queued until scheduler dispatches.
  • Cannot re-send once status ∈ {delivered, bounced, suppressed, failed, superseded}.

State machine:

queued ──► rendering ──► sending ──► sent ──► delivered ──► opened ──► clicked
│ │ │ │
│ │ ▼ ▼
│ │ failed bounced
│ │
│ ▼
│ failed (render error)


suppressed (preference block)


superseded (rolled into digest)

2. NotificationTemplate (root)

interface NotificationTemplate {
id: TemplateId;
tenantId: TenantId | null; // null = platform-global template
key: string; // "enrollment.created.email"
version: SemVer;
channel: 'email' | 'sms' | 'push' | 'inapp' | 'webhook';
category: NotificationCategory;
locales: Record<Locale, LocalizedBody>;
subjectTemplate?: string; // email/push only; supports variable interp
variables: VariableSchema[]; // declared inputs with types
defaultPriority: 'low' | 'normal' | 'high' | 'critical';
status: 'draft' | 'active' | 'deprecated';
createdBy: UserId;
createdAt: ISODate;
publishedAt?: ISODate;
deprecatedAt?: ISODate;
}

interface LocalizedBody {
body: string; // MJML | plaintext | markdown depending on channel
previewText?: string; // email preview header
bodyFormat: 'mjml' | 'markdown' | 'plaintext' | 'html';
fallbackLocale?: Locale;
}

interface VariableSchema {
name: string;
type: 'string' | 'number' | 'boolean' | 'date' | 'url' | 'object';
required: boolean;
description: string;
piiClass?: 'none' | 'low' | 'medium' | 'high';
}

Invariants:

  • (tenantId, key, version) is unique.
  • At least one locale must be defined; a fallbackLocale chain must terminate.
  • Variable names referenced in body must all be declared in variables (validated at publish time).
  • Once status = 'active', locales and variables are immutable; bump version instead.
  • Deprecated templates must have a successor key recorded.

3. NotificationPreference (root, one per user)

interface NotificationPreference {
tenantId: TenantId;
userId: UserId;
channels: Record<NotificationCategory, ChannelPreferences>;
quietHours: QuietHours;
digestSchedule: DigestSchedule;
language: Locale;
parentGuardianRouting?: GuardianRouting; // minors only
globalUnsubscribedAt?: ISODate;
updatedAt: ISODate;
version: number; // optimistic concurrency
}

interface ChannelPreferences {
email: 'instant' | 'digest' | 'off';
sms: 'instant' | 'off';
push: 'instant' | 'off';
inapp: 'instant' | 'off'; // cannot be 'off' for security category
}

interface QuietHours {
enabled: boolean;
startLocal: string; // "22:00"
endLocal: string; // "07:00"
timezone: IANATimezone; // "Africa/Nairobi"
overrideForCritical: boolean; // default true
}

interface DigestSchedule {
dailyDigestAtLocal?: string; // "08:00"
weeklyDigestAtLocal?: string; // "Mon 08:00"
}

interface GuardianRouting {
guardianUserIds: UserId[];
ccGuardianOnAcademic: boolean;
routeBillingToGuardian: boolean;
}

Invariants:

  • channels.security.email cannot be 'off' (regulatory override).
  • channels.compliance.inapp cannot be 'off'.
  • Quiet hours must parse as valid HH:mm in IANA timezone.
  • If user DOB indicates minor, parentGuardianRouting must have at least one guardian.

4. WebhookSubscription (root)

interface WebhookSubscription {
id: WebhookId;
tenantId: TenantId;
name: string;
targetUrl: URL;
eventFilters: EventFilter[]; // which domain events trigger this
signingSecret: HMACSecret; // rotated every 90d
signingSecretPrevious?: HMACSecret; // grace period during rotation
status: 'active' | 'paused' | 'disabled';
createdBy: UserId;
createdAt: ISODate;
lastSuccessAt?: ISODate;
lastFailureAt?: ISODate;
consecutiveFailures: number;
autoDisableThreshold: number; // default 20
}

interface EventFilter {
eventType: string; // "enrollment.*" or "assignment.submitted.v1"
predicate?: JSONLogic; // optional filter on payload
}

Invariants:

  • targetUrl must be HTTPS (except in explicit dev override).
  • signingSecret length >= 32 bytes.
  • Auto-disable after 20 consecutive failures; requires manual re-enable.
  • Max 50 event filters per subscription.

5. SuppressionEntry (root)

interface SuppressionEntry {
id: ULID;
tenantId: TenantId;
channel: 'email' | 'sms' | 'push';
address: RecipientAddress; // hashed for storage
reason: 'hard_bounce' | 'complaint' | 'manual' | 'provider_reject';
originatingMessageId?: ULID;
suppressedAt: ISODate;
expiresAt?: ISODate; // null = permanent
overriddenBy?: UserId; // admin unblock
overriddenAt?: ISODate;
}

Value Objects

RecipientAddress

type RecipientAddress =
| { kind: 'email'; email: EmailAddress; displayName?: string }
| { kind: 'phone'; e164: E164Phone }
| { kind: 'push_token'; token: string; platform: 'fcm' | 'apns' | 'web' }
| { kind: 'inapp'; userId: UserId }
| { kind: 'webhook'; url: URL; subscriptionId: WebhookId };

Locale

ICU locale tag with region: en-US, en-KE, fr-FR, sw-KE, ar-SA, hi-IN, es-MX, pt-BR.

SemVer

Strict MAJOR.MINOR.PATCH (no pre-release suffixes).

HMACSecret

32-byte random, stored encrypted at rest, returned only at creation/rotation.

Domain Events (Published)

EventTriggerPayload Key Fields
notification.sent.v1Provider acceptednotificationId, channel, providerName, providerMessageId
notification.delivered.v1Delivery receipt receivednotificationId, deliveredAt
notification.failed.v1Terminal failurenotificationId, reason, lastError
notification.bounced.v1Provider bounce webhooknotificationId, bounceType (hard/soft)
notification.opened.v1Email open pixelnotificationId, userAgent, ip (hashed)
notification.clicked.v1Tracked link clicknotificationId, linkIndex
notification.preference.updated.v1User changed prefsuserId, changedFields
notification.template.published.v1New template version activetemplateId, key, version
notification.webhook.delivery_failed.v1Webhook delivery failed after retriessubscriptionId, eventType, error

Domain Services

NotificationRouter

Decides, given an inbound domain event:

  • Which templates apply? (one-to-many map, keyed by {eventType} → [templateKey])
  • Which users are recipients? (from event payload + identity projections)
  • Which channels? (from user preferences)

PreferenceGate

Pure function: (Notification, Preference) → SendDecision

  • Returns send | suppress(reason) | defer(untilTimestamp) | digest(groupId).

TemplateRenderer

Given a template + variables + locale:

  • Compiles Handlebars AST (cached by {templateId}@{version}).
  • Injects safe helpers: formatDate, formatCurrency, pluralize, link.
  • Rejects unsafe helpers or sandbox escapes.
  • For email: MJML → HTML via mjml-core, then inline CSS.
  • Returns RenderedMessage VO.

DigestEngine

  • Rolling window aggregation per (userId, category, channel).
  • Merges variables from multiple notifications into a digest template.
  • Fires on threshold, timeout, or scheduled digest time.

WebhookDispatcher

  • Signs payload with HMAC-SHA256.
  • POSTs with timeout 10s, retries with jittered exponential backoff (1s, 4s, 16s, 64s, 256s, 1024s).
  • Tracks consecutive failures for auto-disable.

Aggregate Boundaries Diagram

Invariant Enforcement Layer

All invariants enforced in the domain layer (never trust DB-only constraints):

  • Aggregate constructors validate VO inputs.
  • State transitions go through guarded methods.
  • Cross-aggregate rules (e.g., template must exist before notification is created) enforced in application-service transaction script.