Back to Notes

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 NotificationChannel from 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 NotificationService needing to know about them.
  • Strategy (implicit) — each NotificationChannel is itself a swappable delivery strategy for the same send(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 channel send is likely I/O-bound — an HTTP call to an email/SMS provider), each send() 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 if notify() 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 a UserPreferences with {"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.create raises ValueError — 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, call NotificationFactory.register("whatsapp", WhatsAppChannel) — zero changes to NotificationService or existing channels. This is the concrete Open/Closed payoff.
  • Retry with backoff on a failed channel: wrap the channel in a RetryingChannel decorator implementing the same NotificationChannel interface, delegating to the real channel with retry logic around it — the Decorator pattern (see [[OOD Design Patterns]]), same shape as the LoggingProcessor example 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

  1. How do you add a new notification channel without modifying NotificationService? — Implement NotificationChannel, register it via NotificationFactory.register() — the service only ever depends on the interface, never a concrete channel class, so nothing about it changes.
  2. One channel's send() throws an exception. What happens to the others? — Nothing — the try/except around each individual channel call isolates the failure; the loop continues to the next requested channel regardless.
  3. 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 NotificationChannel interface 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)]]