Domain Model
:::info Source
Sourced from services/notification-service/DOMAIN_MODEL.md in the documentation repo.
:::
Ubiquitous Language
| Term | Definition |
|---|---|
| Notification | A single message-delivery intent for one recipient on one channel. |
| Template | A versioned, localized content skeleton identified by a template key. |
| Preference | A user's per-category, per-channel consent and quiet-hour configuration. |
| Digest | A collapsed group of related notifications delivered as one message. |
| Suppression List | Registry of addresses marked undeliverable (hard bounces, complaints). |
| Delivery Receipt | Provider-returned confirmation that the message reached the recipient's infrastructure. |
| Bounce | Provider-returned rejection; soft (retryable) or hard (permanent). |
| Webhook Subscription | Customer-registered HTTPS endpoint subscribing to published events. |
| Template Key | Dotted 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:
tenantIdis immutable after creation.statustransitions follow a defined state machine (see below).- A notification with
channel = 'email'must haverecipientAddress.emailpresent. - A notification with
channel = 'inapp'does NOT consult provider; status goesqueued → sent → deliveredon WebSocket ack or next poll. attempts.length <= 6unless channel isinapp(no retries).- If
scheduledFor > now(), status staysqueueduntil 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
fallbackLocalechain must terminate. - Variable names referenced in body must all be declared in
variables(validated at publish time). - Once
status = 'active',localesandvariablesare 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.emailcannot be'off'(regulatory override).channels.compliance.inappcannot be'off'.- Quiet hours must parse as valid HH:mm in IANA timezone.
- If user DOB indicates minor,
parentGuardianRoutingmust 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:
targetUrlmust be HTTPS (except in explicit dev override).signingSecretlength >= 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)
| Event | Trigger | Payload Key Fields |
|---|---|---|
notification.sent.v1 | Provider accepted | notificationId, channel, providerName, providerMessageId |
notification.delivered.v1 | Delivery receipt received | notificationId, deliveredAt |
notification.failed.v1 | Terminal failure | notificationId, reason, lastError |
notification.bounced.v1 | Provider bounce webhook | notificationId, bounceType (hard/soft) |
notification.opened.v1 | Email open pixel | notificationId, userAgent, ip (hashed) |
notification.clicked.v1 | Tracked link click | notificationId, linkIndex |
notification.preference.updated.v1 | User changed prefs | userId, changedFields |
notification.template.published.v1 | New template version active | templateId, key, version |
notification.webhook.delivery_failed.v1 | Webhook delivery failed after retries | subscriptionId, 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
RenderedMessageVO.
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.