Design a Notification System (LLD)
1. Problem & How This Differs From the HLD Version
This is the class-design version: model notification channels (email, SMS, push), user preferences per channel, and the fan-out to multiple channels for one event — as clean, extensible objects. The distributed-systems version (Kafka fan-out, delivery guarantees at scale, retry queues across services) is the [[Message Queues & Kafka]] and Notification System HLD territory; this page is what a machine-coding round actually asks: can you model channels and preferences without a wall of if channel == "email" branching.
2. Requirements
Functional: send a notification through one or more channels (email, SMS, push); respect per-user, per-channel preferences (a user may opt out of SMS); support multiple simultaneous observers of one event (a "new message" event triggers push AND updates an in-app badge count).
Non-functional: adding a new channel shouldn't require modifying the dispatch core (Open/Closed — see [[SOLID Principles]]); a failure in one channel shouldn't block delivery on others.
3. Class Diagram
classDiagram
class NotificationChannel {
<<interface>>
+send(user, message) bool
}
class EmailChannel {
+send(user, message) bool
}
class SMSChannel {
+send(user, message) bool
}
class PushChannel {
+send(user, message) bool
}
class NotificationFactory {
+create(channel_type) NotificationChannel
}
class UserPreferences {
+str user_id
+set~str~ enabled_channels
}
class NotificationService {
-dict~str,NotificationChannel~ channels
-dict~str,UserPreferences~ preferences
+notify(user_id, message, requested_channels)
+subscribe(observer)
}
class NotificationObserver {
<<interface>>
+on_notification_sent(user_id, channel, success)
}
NotificationChannel <|.. EmailChannel
NotificationChannel <|.. SMSChannel
NotificationChannel <|.. PushChannel
NotificationFactory --> NotificationChannel
NotificationService --> NotificationChannel
NotificationService --> UserPreferences
NotificationService --> NotificationObserver
4. Design Patterns Used
- Factory — construct the right
NotificationChannelfrom a string/enum without the caller knowing concrete classes (see [[OOD Design Patterns]]). - Observer — other parts of the system (analytics, delivery-metrics logging, an in-app badge updater) subscribe to "a notification was sent" without
NotificationServiceneeding to know about them. - Strategy (implicit) — each
NotificationChannelis itself a swappable delivery strategy for the samesend(user, message)contract.
5. Implementation
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
class NotificationChannel(ABC):
@abstractmethod
def send(self, user_id: str, message: str) -> bool: ...
class EmailChannel(NotificationChannel):
def send(self, user_id: str, message: str) -> bool:
print(f"[email] to {user_id}: {message}")
return True
class SMSChannel(NotificationChannel):
def send(self, user_id: str, message: str) -> bool:
print(f"[sms] to {user_id}: {message}")
return True
class PushChannel(NotificationChannel):
def send(self, user_id: str, message: str) -> bool:
print(f"[push] to {user_id}: {message}")
return True
class NotificationFactory:
_registry: dict[str, type[NotificationChannel]] = {
"email": EmailChannel,
"sms": SMSChannel,
"push": PushChannel,
}
@classmethod
def create(cls, channel_type: str) -> NotificationChannel:
if channel_type not in cls._registry:
raise ValueError(f"Unknown channel: {channel_type}")
return cls._registry[channel_type]()
@classmethod
def register(cls, channel_type: str, channel_cls: type[NotificationChannel]) -> None:
cls._registry[channel_type] = channel_cls # new channels register, don't edit create()
@dataclass
class UserPreferences:
user_id: str
enabled_channels: set[str] = field(default_factory=lambda: {"email", "push"})
class NotificationObserver(ABC):
@abstractmethod
def on_notification_sent(self, user_id: str, channel: str, success: bool) -> None: ...
class NotificationService:
def __init__(self):
self._channels: dict[str, NotificationChannel] = {}
self._preferences: dict[str, UserPreferences] = {}
self._observers: list[NotificationObserver] = []
def set_preferences(self, prefs: UserPreferences) -> None:
self._preferences[prefs.user_id] = prefs
def subscribe(self, observer: NotificationObserver) -> None:
self._observers.append(observer)
def notify(self, user_id: str, message: str, requested_channels: list[str]) -> None:
prefs = self._preferences.get(user_id, UserPreferences(user_id=user_id))
for channel_type in requested_channels:
if channel_type not in prefs.enabled_channels:
continue # respect opt-out, silently skip — not an error
if channel_type not in self._channels:
self._channels[channel_type] = NotificationFactory.create(channel_type)
try:
success = self._channels[channel_type].send(user_id, message)
except Exception:
success = False # one channel failing must not block the others
for observer in self._observers:
observer.on_notification_sent(user_id, channel_type, success)
# Example observer: analytics logging, decoupled from delivery logic
class AnalyticsObserver(NotificationObserver):
def on_notification_sent(self, user_id, channel, success):
print(f"[analytics] {user_id} via {channel}: {'sent' if success else 'failed'}")
6. Why the try/except Around Each Channel Send Matters
Without it, one channel raising (say, the email provider's SDK throwing on a malformed address) would propagate up through the for loop and abort delivery to every subsequent channel in the same call — a push notification that should have gone out regardless of the email failure never does. Isolating each channel's failure is a small but real correctness requirement, not defensive-programming paranoia — it directly maps to the non-functional requirement in §2 ("a failure in one channel shouldn't block delivery on others").
7. Concurrency
- If
notify()fans out to channels concurrently (e.g., via a thread pool, since each channelsendis likely I/O-bound — an HTTP call to an email/SMS provider), eachsend()call is independent and doesn't share mutable state with the others — no locking needed between channels. The one shared structure,self._channels(lazily populated per channel type), needs a lock ifnotify()is called concurrently for different users hitting a not-yet-created channel simultaneously — same lazy-creation race as [[Rate Limiter (OOD)]]'s bucket dictionary. - Observers are called synchronously in the loop above; if an observer is slow (e.g., writing to a database), it delays returning from
notify(). A more scalable design pushes observer notifications onto a queue instead of calling them inline — foreshadowing the HLD version's actual Kafka-based fan-out.
8. Edge Cases
- User has no preferences set:
notify()defaults to aUserPreferenceswith{"email", "push"}enabled rather than erroring or sending nothing — a deliberate default, worth stating explicitly rather than leaving implicit. - Requested channel not in the registry:
NotificationFactory.createraisesValueError— surfaces a config/typo bug loudly rather than silently dropping the notification. - All channels opted out:
notify()simply sends nothing and calls no observers — correct, since there's nothing to report, though a real system might still want an observer call noting "notification suppressed by user preferences" for analytics completeness (a legitimate follow-up to discuss).
9. Extensibility
- New channel (e.g., WhatsApp, Slack): implement
NotificationChannel, callNotificationFactory.register("whatsapp", WhatsAppChannel)— zero changes toNotificationServiceor existing channels. This is the concrete Open/Closed payoff. - Retry with backoff on a failed channel: wrap the channel in a
RetryingChanneldecorator implementing the sameNotificationChannelinterface, delegating to the real channel with retry logic around it — the Decorator pattern (see [[OOD Design Patterns]]), same shape as theLoggingProcessorexample there. - Per-channel rate limiting (don't spam a user with SMS): another decorator layer, composing a [[Rate Limiter (OOD)]] instance in front of a channel's
send().
10. Interview Drills
- How do you add a new notification channel without modifying
NotificationService? — ImplementNotificationChannel, register it viaNotificationFactory.register()— the service only ever depends on the interface, never a concrete channel class, so nothing about it changes. - One channel's
send()throws an exception. What happens to the others? — Nothing — thetry/exceptaround each individual channel call isolates the failure; the loop continues to the next requested channel regardless. - How would you evolve this into the distributed HLD version? — Replace the synchronous observer calls and direct channel dispatch with a message published to a Kafka topic; each channel becomes its own consumer group reading that topic independently — see [[Message Queues & Kafka]]'s fan-out pattern. The
NotificationChannelinterface and per-user preference logic stay conceptually the same; only the delivery mechanism between "event happened" and "channel sends it" changes from direct in-process calls to an async, durable queue.
Cross-links
[[OOD Design Patterns]] · [[SOLID Principles]] · [[Message Queues & Kafka]] · [[Rate Limiter (OOD)]]