Design a Notification System
Multi-channel (email/SMS/push) delivery with templates, preferences, retries, and pluggable providers.
Problem Statement
Design the object model for a notification system that accepts product events such as order shipped, password reset, payment failed, or promotion started and delivers them through multiple channels.
The system must support email, SMS, and push today, render messages from reusable templates, respect per-user channel preferences, retry transient failures with backoff, and allow decorators such as dedupe or rate limiting around any channel.
The core design goal is extensibility: adding a WhatsApp, in-app, or Slack channel should mean implementing NotificationChannel and registering it, not editing the dispatcher that publishes notifications.
Business context
Notifications are the connective tissue of marketplaces, fintech apps, productivity tools, and delivery platforms. A checkout service, fraud service, or logistics service should publish a business event without knowing provider APIs, templates, retry rules, or opt-in preferences.
Interviewers like this problem because it compresses several production concerns into a small LLD surface: Strategy for channels, Observer for pub-sub fan-out, Factory Method for channel creation, Decorator for cross-cutting delivery controls, and careful boundaries around templates, preferences, and retries.
Functional Requirements
Accept a notification request containing user id, template name, dynamic template data, and optional explicit channel names.
Render a message from a named template before dispatching it to channels.
Store per-user channel preferences so users can opt into email, SMS, push, or any future channel.
Dispatch each notification to all subscribed channel observers that match the resolved delivery targets.
Provide email, SMS, and push channel implementations behind one NotificationChannel interface.
Retry transient channel failures with a bounded exponential backoff policy.
Allow decorators such as dedupe and rate limiting to wrap any channel without changing the channel implementation.
Allow a new channel to be added by registering a supplier and subscribing the channel, with no change to NotificationService.publish.
Non-Functional Requirements
Extensibility
Channel addition must be additive. The dispatcher depends on NotificationSubscriber and NotificationChannel, not on concrete classes or switch statements.
Reliability
Transient provider failures should be retried a small number of times with backoff, then surfaced as a delivery failure instead of being silently swallowed.
User preference correctness
A user who opted out of SMS must not receive SMS just because a producer requested all channels. Preference resolution is part of the service boundary.
Low coupling
Business producers publish Notification objects; they do not know provider SDKs, template storage, preference storage, decorators, or retry policy.
Observability
Each channel attempt should be measurable by channel, template, user segment, attempt count, success, failure, and dedupe or rate-limit decision.
Requirement Clarification
QIs delivery synchronous or asynchronous?
For the base LLD, the dispatcher runs in memory and calls subscribers directly so the object model is clear. In production, the same observer boundary can publish jobs to a queue per channel.
QDo we guarantee exactly once delivery?
No. Provider APIs and retries usually produce at-least-once behavior. The design includes a dedupe decorator so the system can reduce duplicates for a stable idempotency key.
QWhere are templates and preferences stored?
Use in-memory maps in the reference implementation. The interfaces are intentionally narrow so a repository or external preference service can replace them later.
QWhat happens when a user has no saved preference?
Default to all subscribed channels unless the notification explicitly requests a smaller channel set. A real product may choose safer defaults based on consent policy.
QAre provider details like SendGrid, Twilio, or FCM in scope?
Only as channel implementations. The model treats them as adapters behind NotificationChannel so provider SDK details do not leak into the dispatcher.
UML Class Diagram
Sequence Diagram
Entity Identification
Notification
Immutable request object representing who should be notified, which template to use, which dynamic values to render, and any explicitly requested channels.
NotificationTemplate
Owns the message body and placeholder replacement. It keeps rendering rules out of channels so channels only deliver already-rendered content.
NotificationService
Facade and dispatcher. Stores templates and preferences, resolves delivery targets, and publishes to subscribed observers.
NotificationSubscriber
Observer interface. A subscriber declares the channel name it handles and reacts when the service publishes a matching notification.
NotificationChannel
Strategy interface for delivery. Email, SMS, push, and future channels all expose the same send operation.
ChannelFactory
Registry-backed factory. It maps channel names to suppliers and creates channels without making the dispatcher depend on concrete channel constructors.
RetryPolicy
Reusable retry executor that catches transient runtime failures, waits with exponential backoff, and stops after a bounded attempt budget.
ChannelDecorator
Base wrapper for cross-cutting channel behavior. DedupingChannel and RateLimitedChannel demonstrate how controls can be layered around any channel.
EmailChannel / SmsChannel / PushChannel
Concrete strategies for provider-specific delivery. The sample prints to the console, but real implementations would call provider SDKs.
Design Patterns Used
NotificationService keeps a list of NotificationSubscriber observers. Publishing a notification fans out to every subscriber whose channel matches the user's target set.
NotificationChannel is the delivery strategy. Email, SMS, push, and future channels vary provider behavior behind a single send method.
ChannelFactory.create is the creation seam. New channel suppliers are registered by name and instantiated through the factory, so callers do not construct concrete channels directly.
DedupingChannel and RateLimitedChannel wrap any NotificationChannel. Cross-cutting delivery policy is composed around a channel instead of being copied into each channel class.
Step-by-Step Design
1Model a notification as an immutable command-like value
The producer supplies user id, template name, template data, and optionally a smaller set of channel names. Empty requested channels means the service should use stored user preferences.
Notification notification = Notification.of( "user-7", "orderReady", Map.of("name", "Asha", "orderId", "EIQ-42"), List.of() );2Represent channels as strategies
Every provider implements NotificationChannel. The dispatcher asks for name and calls send; it never checks whether the object is email, SMS, push, or a future channel.
public interface NotificationChannel { String name(); void send(Notification notification, String message); }3Render templates before delivery
NotificationTemplate transforms data into a final message once. Channels should not know placeholder syntax, localization rules, or template storage.
4Use Observer for fan-out
The service stores NotificationSubscriber objects. publish resolves target channel names, then notifies matching subscribers. That is the pub-sub boundary that can later become queue publishing.
for (NotificationSubscriber subscriber : subscribers) { if (targets.contains(subscriber.channelName())) { subscriber.onNotification(notification, message, retryPolicy); } }5Create channels through a registry-backed factory
ChannelFactory maps names to suppliers. Adding a new channel means registering whatsapp with WhatsAppChannel::new and subscribing that channel; NotificationService.publish stays closed for modification.
6Compose retry and decorators around delivery
ChannelSubscriber invokes RetryPolicy for each send. Decorators such as DedupingChannel and RateLimitedChannel can be layered around any channel before subscription.
Complete Java Implementation
Explanation of Every Class
Notification
Immutable input to the dispatcher. It carries the user id, template name, dynamic values, and optional explicit channel names; empty requested channels delegates the choice to preferences.
NotificationChannel
Strategy interface for delivery. Every concrete provider exposes name and send, keeping provider-specific behavior outside the dispatcher.
EmailChannel / SmsChannel / PushChannel
Concrete channel strategies. The demo prints messages, but real classes would adapt SendGrid, Twilio, FCM, or another provider while keeping the same interface.
ChannelDecorator / DedupingChannel / RateLimitedChannel
Decorator hierarchy. The base wrapper delegates name and exposes the wrapped channel; dedupe suppresses repeated keys, while rate limiting rejects sends that arrive too soon.
NotificationTemplate
Template value object that replaces placeholders like {{name}} with supplied data. Rendering happens before channel fan-out so every channel receives the same message.
NotificationService
Main facade and dispatcher. It registers templates, stores user preferences, subscribes observers, resolves delivery targets, and publishes matching notifications.
NotificationSubscriber / ChannelSubscriber
Observer abstraction plus the standard adapter from a channel to an observer. ChannelSubscriber invokes retry policy around the channel's send method.
RetryPolicy
Bounded exponential-backoff executor. It retries runtime failures, doubles the wait after each failure, and throws after the maximum attempt count.
ChannelFactory
Registry-backed factory method implementation. It maps channel names to suppliers, creates all default channels, and lets future channels register without touching the service.
Main
Demonstrates wiring: create the factory and service, register a template, decorate and subscribe channels, store preferences, and publish the same notification twice to show dedupe.
Dry Run
Sample input
User user-7 prefers email and push. Template orderReady renders to Hi Asha, order EIQ-42 is ready. The app publishes the same notification twice with no explicit channel override.
| Step | Dispatcher state | Channels considered | Retry or decorator behavior | Outcome |
|---|---|---|---|---|
| 1 | First publish renders template and resolves preferences | email, SMS, push | Preference filter removes SMS | Targets are email and push |
| 2 | Email subscriber receives the event | RetryPolicy attempt 1 wraps DedupingChannel and RateLimitedChannel | Email sends successfully | |
| 3 | Push subscriber receives the event | push | RetryPolicy attempt 1 wraps the same decorator chain | Push sends successfully |
| 4 | Second publish renders the same message | email and push | DedupingChannel finds the same user/template/channel/message key | Both duplicate sends are skipped |
| 5 | Later, WhatsAppChannel is registered and subscribed | email, SMS, push, whatsapp | No change to NotificationService.publish | Future notifications can include whatsapp |
The trace highlights the important boundaries: preferences narrow the target set before fan-out, retry is reusable, and decorators apply uniformly to each channel strategy.
Complexity Analysis
| Operation | Time | Space | Note |
|---|---|---|---|
| publish | O(K × M + S) | O(C) | K template values, M message length, S subscribers, C resolved target channels. |
| setPreferences | O(C) | O(C) | Copies the user's channel names into a set. |
| registerTemplate | O(1) | O(1) | Hash-map insertion by template name. |
| retry one channel | O(A) | O(1) | A is the bounded maximum attempt count; sleep time is operational latency, not CPU work. |
The in-memory implementation is intentionally simple. At scale, publish should enqueue one delivery job per target channel so provider latency and retry waits do not block the producer thread.
Extensibility
New channel
Implement NotificationChannel, register a supplier with ChannelFactory, and subscribe the created channel. The service loop still sees only NotificationSubscriber.
New decorator
Create another ChannelDecorator such as audit logging, circuit breaking, consent enforcement, or payload redaction, then wrap channels during wiring.
Persistent templates and preferences
Replace the maps in NotificationService with repositories. The publish algorithm remains the same: load template, load preferences, notify subscribers.
Asynchronous delivery
Change ChannelSubscriber to enqueue a delivery job instead of calling send directly. Workers can reuse NotificationChannel, decorators, and RetryPolicy.
Alternative Designs
Switch-based dispatcher
Put a switch on channel name inside NotificationService.publish and call email, SMS, or push directly.
Tradeoffs
Simple for three channels, but every new channel edits the hot dispatcher and mixes provider concerns with orchestration.
Queue per channel
Publish one job per target channel to message queues, and let dedicated workers perform retries and provider calls.
Tradeoffs
Better isolation and throughput, but adds queue infrastructure, idempotency keys, delayed retries, and operational complexity.
Rules engine for preferences
Evaluate channel eligibility through a policy engine using user consent, country, event type, quiet hours, and product rules.
Tradeoffs
Powerful for regulated products, but overkill for a core LLD unless the interviewer asks for advanced preference rules.
Common Mistakes
- ×
Hard-coding if channel == email logic inside the dispatcher, which violates the open-closed goal.
- ×
Letting channels render templates, causing email, SMS, and push to duplicate placeholder logic.
- ×
Ignoring user preferences when a producer explicitly requests many channels.
- ×
Retrying forever or retrying non-transient failures without a maximum attempt budget.
- ×
Copying rate-limit or dedupe logic into every concrete channel instead of using decorators.
- ×
Throwing away delivery results and metrics, making provider failures impossible to debug.
- ×
Using provider SDK classes as method parameters in the domain model, leaking infrastructure into the core design.
Follow-up Interview Questions
QHow do you add WhatsApp without touching existing dispatcher code?
Create WhatsAppChannel implements NotificationChannel, register it in ChannelFactory, wrap it with any decorators, and subscribe it. NotificationService.publish remains unchanged.
QHow would you make delivery asynchronous?
Keep NotificationService as the target resolver, but have each subscriber enqueue a delivery job to a channel queue. Workers own provider calls, retry delays, dead-letter handling, and metrics.
QHow do you prevent duplicate messages during retries?
Generate an idempotency key from notification id, user id, template, channel, and rendered payload. Store it in a durable dedupe table or provider idempotency header before sending.
QWhere do quiet hours or consent rules belong?
In preference resolution or a policy layer before fan-out. Channels should not decide whether a user consented; they should only deliver approved messages.
QWhat changes if templates are localized?
Template lookup becomes keyed by template name plus locale, and rendering may use a richer engine. The service still renders before fan-out and channels stay unchanged.
Production Considerations
Durable outbox
Use an outbox table or event log so business transactions and notification publishing cannot get out of sync when a process crashes.
Provider isolation
Run email, SMS, and push workers independently with per-provider rate limits, circuit breakers, credentials, and dashboards.
Consent and compliance
Track opt-ins, unsubscribe state, country restrictions, quiet hours, and audit trails. Preference checks must happen before any provider call.
Idempotency and dedupe
Persist idempotency keys with status so retries, worker restarts, and duplicate events do not spam users.
Observability and dead letters
Emit metrics and structured logs per attempt. After retries are exhausted, move the job to a dead-letter queue with enough context to replay safely.
What Interviewers Look For
Does the candidate keep channel addition additive through NotificationChannel and factory registration?
Do templates and preferences live outside channel implementations?
Is pub-sub fan-out modeled explicitly with an observer/subscriber boundary?
Are retries bounded, observable, and separated from provider code?
Can decorators be composed around any channel without changing concrete channels?
Quiz
0/5 answered
1.Which pattern lets email, SMS, and push vary behind the same **send** operation?
2.Why does **NotificationService** store **NotificationSubscriber** objects?
3.What is the best way to add a rate limit to every channel?
4.Why should templates be rendered before channel delivery?
5.What should happen when retry attempts are exhausted?
Practice Variants
Add WhatsAppChannel
BeginnerImplement a new NotificationChannel, register it with ChannelFactory, add it to a user's preferences, and verify the dispatcher code remains unchanged.
Add quiet hours
IntermediateIntroduce a preference policy that suppresses non-urgent channels during a user's quiet-hours window while allowing critical alerts.
Move delivery to queues
AdvancedHave each subscriber enqueue a delivery job and build a worker that applies decorators, retry, dead-letter handling, and idempotency.
Flashcards
Cheat Sheet
Entities: Notification, NotificationTemplate, NotificationService, NotificationSubscriber, NotificationChannel, ChannelFactory, RetryPolicy, ChannelDecorator.
Patterns: Observer for pub-sub fan-out, Strategy for channels, Factory Method for channel creation, Decorator for dedupe and rate limiting.
Flow: producer publishes Notification → service renders template → resolves explicit channels or user preferences → notifies matching subscribers → subscriber invokes retry → channel sends.
Open-closed seam: new channel = implement NotificationChannel + register supplier + subscribe. Do not edit NotificationService.publish.
Reliability: retries are bounded; production should add durable outbox, idempotency keys, queue workers, metrics, and dead-letter handling.
Common smell: switch statements in the dispatcher for channel type, duplicated template rendering in channels, or unbounded retry loops.
References
- BookHead First Design Patterns — Freeman & Robson
- BookEnterprise Integration Patterns — Gregor Hohpe and Bobby Woolf
- DocsRefactoring Guru — Observer Pattern
- DocsRefactoring Guru — Decorator Pattern