Overview
A notification service sends messages to users over channels such as email, SMS, push, and in-app, based on events (order shipped, password reset). The LLD focuses on clean abstractions: a Channel interface with provider adapters, templates rendered with data, user preferences (opt-outs, quiet hours), priority, retries with fallback providers, and delivery tracking.
Patterns fit naturally: Strategy/Adapter for channels and providers, Template Method or template engines for content, Observer or event subscriptions for triggers, Chain of Responsibility for preference and rate-limit checks, and Decorator for retries and logging.
You hand over a message and the recipient's preferences. The post office decides whether it goes by courier, regular mail, or phone call, retries if nobody is home, and records the delivery.
When to use it
- Interview prompt: 'Design a notification system'.
- Any product sending transactional or marketing messages.
- Multi-provider messaging with failover.
Where it shows up in interviews
Recognize it when: email, SMS, push with preferences.
- Design a notification service
- Design an alerting system
Recognize it when: if Twilio fails, use another provider.
- Design an OTP service
- Design transactional email
Where it is used in real software
Providers behind notification services for SMS, email, and push.
Notification infrastructure products with templates, preferences, and routing.
Internal notification platforms route millions of messages with priorities and preferences.
Key terms
- Channel
- Delivery medium: email, SMS, push, in-app.
- Provider
- Vendor implementing a channel (SES, Twilio).
- Template
- Content with placeholders rendered per user.
- Preference
- User opt-ins, quiet hours, channel choices.
- Delivery status
- Queued, sent, delivered, failed.
How it works, step by step
- 1Define the request
userId, type, data, priority.
- 2Resolve preferences
Which channels are allowed now.
- 3Render the template
Per channel and locale.
- 4Send via channel providers
With retries and fallback.
- 5Record status
For deduplication, audit, and analytics.
STEP 1OrderShipped arrives. User 42 allows push and email, not SMS.
Routing decisions
Different notification types
| Type | Priority | Channels | Respect quiet hours? |
|---|---|---|---|
| Password reset OTP | Critical | SMS, fallback email | No |
| Order shipped | Normal | Push + email | Push: yes |
| Weekly digest | Low | Yes | |
| Security alert | Critical | Push + email + SMS | No |
NOWType: Password reset OTP | Priority: Critical | Channels: SMS, fallback email | Respect quiet hours?: No
Priority and preferences are policy data, not hard-coded branches in senders.
Implementation
type ChannelName = "email" | "sms" | "push";type Message = { to: string; subject?: string; body: string }; interface Channel { name: ChannelName; send(msg: Message): Promise<void> } class FallbackChannel implements Channel { constructor(readonly name: ChannelName, private providers: Channel[]) {} async send(msg: Message) { let lastError: unknown; for (const p of this.providers) { try { return await p.send(msg); } catch (e) { lastError = e; } } throw lastError; }} type Prefs = { channels: ChannelName[]; quietHours?: [number, number]; contact: Record<ChannelName, string> };type Request = { eventId: string; userId: string; type: string; priority: "critical" | "normal" | "low"; data: Record<string, string> }; class NotificationService { private sent = new Set<string>(); constructor( private channels: Map<ChannelName, Channel>, private prefs: (userId: string) => Promise<Prefs>, private templates: (type: string, channel: ChannelName, data: Record<string, string>) => Message["body"], private hour = () => new Date().getHours(), ) {} async notify(req: Request) { const p = await this.prefs(req.userId); const quiet = p.quietHours && this.hour() >= p.quietHours[0] && this.hour() < p.quietHours[1]; const targets = p.channels.filter((c) => req.priority === "critical" || !(quiet && c === "push")); await Promise.allSettled(targets.map(async (c) => { const key = `${req.eventId}:${c}`; if (this.sent.has(key)) return; // idempotent per event and channel await this.channels.get(c)!.send({ to: p.contact[c], body: this.templates(req.type, c, req.data) }); this.sent.add(key); })); }}Complexity and performance
Plus provider latency.
Registered in the map.
Trade-offs
Sending inline is simple but slow; production systems enqueue and send from workers.
In-memory dedupe is lost on restart; store delivery records durably.
Variants and related techniques
Combine low-priority notifications into periodic summaries.
Persist notifications for users to read later.
Common mistakes
- Hard-coded channel logic in business services.
Fix: Services publish events; the notification service decides channels.
- Ignoring user preferences and regulations.
Fix: Honor opt-outs and quiet hours; required for compliance.
- Duplicate sends on retry.
Fix: Deduplicate by event ID and channel.
Interview questions
How would you support a new channel like WhatsApp?
Implement the Channel interface with a WhatsApp provider adapter, add templates for it, register it in the channel map, and let users opt in through preferences; no changes to existing senders.
How do you handle provider outages?
Wrap channels with retry decorators and fallback providers, send asynchronously from a queue with backoff, and track delivery status so failures can be retried or alerted.
Practice problems
| Problem | Difficulty | What it trains |
|---|---|---|
| Multi-channel notifier with preferences | Medium | Strategy and adapters. |
| Add retries, fallback, and dedupe | Hard | Reliability. |