Design a Notification System

The system every company actually builds: multi-channel delivery, templates, preferences, retries, dedup — and the patterns earning their keep together.

LLDOODpatternsasync

The real-world analogy: sorting bins and courier runner dispatchers

Imagine a massive corporate mailroom handling outgoing client messaging:

  1. The Inbound Request (Event): Callers do not walk in carrying full letters or hardcoded paper mail envelopes. They simply slide a standard index card under the window containing an event slip (e.g. Event: ORDER_SHIPPED, data: {order_id: 123}).
  2. Preference Routing Store: The mail clerk looks up the recipient's file folder (Preference Store) to determine routing: "John wants this by SMS, email, and push."
  3. Dedicated Couriers (Strategy Adapters): The mailroom has three specialized courier drivers standing ready: an Email courier, an SMS courier, and a Push courier. Each courier speaks the specific language of their delivery medium (SMTP, Twilio SDK, FCM SDK).
  4. Idempotency Stamp (Deduplication): To ensure that network glitches or duplicate queue messages do not send John two SMS notifications, the mail clerk stamps each outgoing slip with a unique request ID. If a courier attempts to log out a letter with a stamped ID that has already been dispatched in the ledger, the mail clerk halts the delivery as a no-op.

Scope it first

"A service other teams call to notify users — email, SMS, push. Templates with variables, user channel preferences and opt-outs, retries on provider failure, no duplicates, rate limits so we don't spam. OK?"

Why this one matters disproportionately: you will almost certainly build or extend one at work — every product notifies — and it's the LLD where the pattern vocabulary stops being academic: Strategy, Factory, Decorator, Observer and a queue all earn their keep in one design. It's also the cleanest LLD→HLD bridge in the catalog.


UML Class Diagram

The flow: a caller says notify(user, ORDER_SHIPPED, {orderId: 42}) — note callers speak in events, never in channels or copy; that decision is the whole API. The service resolves preferences ("Asha: push for shipping, email for billing, never SMS"), renders the template per channel, and dispatches.


Where the patterns live (the showcase)

  • Strategy — Channel. One interface, three (then ten) implementations. Adding WhatsApp = one class (Open/Closed, again).
  • Factory + registryChannelFactory.for(channelType) so the dispatch loop never names a concrete class (patterns-in-depth).
  • Adapter — each channel wraps a vendor SDK (SES, Twilio, FCM) behind the Channel port; swapping SMS providers touches one file.
  • Decorator — retries, rate limiting and metrics wrap any channel: Metered(RateLimited(Retrying(SmsChannel))) — assembled at startup, testable in isolation.
  • Observer — the caller side. Product code publishes OrderShipped; the notification service is one subscriber. Shipping code knows nothing about emails — which is why marketing can add a "review your purchase" notification without touching the orders team (event-driven decoupling in miniature).
  • Template Method-ish rendering — one Template per (event, locale), rendering per channel: email gets HTML + subject, SMS gets 160 chars, push gets title + body. Content shaping is a template concern, never an if-chain in the channel.

That Observer seam is the decoupling that makes the whole thing extensible — the orders service publishes one event and never learns who listens.

Observer — publish / subscribe fan-outtime O(subscribers) per publishspace O(subscribers)
SubjectChartAlertsLogger

solid = subscribed · dashed = not listening · the subject calls the same method on every subscriber

1/15A subject and 3 possible observers. The subject holds a list of subscribers and knows nothing else about them — that decoupling is the whole pattern.

subscribers = 0

Think it through like the interview

Think it through: Design a Notification SystemLLD Classic — the pattern showcase0/5 stages

PROBLEMA service other teams call to notify users via email, SMS and push: templates, user preferences, retries, no duplicates, rate limits.

  1. 1

    Design the API before the classes

    What should callers pass — a message, or something else? This decision decides everything downstream.

  2. 2

    Let the patterns earn their keep

    Channels vary, vendors vary, cross-cutting concerns stack. Which pattern goes where?

    unlocks after the stage above
  3. 3

    Go async by construction

    Twilio is down for 10 minutes. What may callers of notify() experience?

    unlocks after the stage above
  4. 4

    Make 'no duplicates' a mechanism, not a hope

    The queue is at-least-once and providers time out ambiguously. Where exactly do duplicates die?

    unlocks after the stage above
  5. 5

    The two rules only operators know

    What checks happen at SEND time rather than enqueue time, and why per-user rate limits?

    unlocks after the stage above

Implementation

Below are complete implementations with thread-safe message queues and retry decorator mechanics.

Python

Python
import time
import queue
import threading
from abc import ABC, abstractmethod
from typing import Dict, List, Optional

class NotificationRequest:
    def __init__(self, request_id: str, user_id: str, event_type: str, payload: dict):
        self.request_id = request_id
        self.user_id = user_id
        self.event_type = event_type
        self.payload = payload

class Channel(ABC):
    @abstractmethod
    def send(self, user_id: str, content: str) -> bool:
        pass

class EmailChannel(Channel):
    def send(self, user_id: str, content: str) -> bool:
        print(f"[Email] Sending to {user_id}: {content}")
        return True

class SmsChannel(Channel):
    def send(self, user_id: str, content: str) -> bool:
        print(f"[SMS] Sending to {user_id}: {content}")
        return True

# Decorator pattern for retry logic
class RetryingChannelDecorator(Channel):
    def __init__(self, inner: Channel, retries: int = 3):
        self.inner = inner
        self.retries = retries
        
    def send(self, user_id: str, content: str) -> bool:
        for attempt in range(self.retries):
            try:
                if self.inner.send(user_id, content):
                    return True
            except Exception:
                time.sleep(2 ** attempt)
        return False

class PreferenceStore:
    def __init__(self):
        self.preferences: Dict[str, List[str]] = {} # user_id -> allowed channels
        self.lock = threading.Lock()
        
    def get_preferred_channels(self, user_id: str) -> List[str]:
        with self.lock:
            return self.preferences.get(user_id, ["email", "sms"])

class NotificationDispatcher:
    def __init__(self, preference_store: PreferenceStore):
        self.queue = queue.Queue()
        self.pref_store = preference_store
        self.channels: Dict[str, Channel] = {
            "email": RetryingChannelDecorator(EmailChannel()),
            "sms": RetryingChannelDecorator(SmsChannel())
        }
        self.sent_records = set()
        self.lock = threading.Lock()
        self.running = False
        
    def enqueue(self, request: NotificationRequest):
        self.queue.put(request)
        
    def start_worker(self):
        self.running = True
        self.worker_thread = threading.Thread(target=self._process_queue, daemon=True)
        self.worker_thread.start()

    def _process_queue(self):
        while self.running:
            try:
                request = self.queue.get(timeout=1.0)
            except queue.Empty:
                continue
                
            preferred = self.pref_store.get_preferred_channels(request.user_id)
            for ch_name in preferred:
                dedup_key = (request.request_id, ch_name)
                with self.lock:
                    if dedup_key in self.sent_records:
                        continue # Already processed
                    self.sent_records.add(dedup_key)
                
                channel = self.channels.get(ch_name)
                if channel:
                    content = f"Event {request.event_type} triggered. Payload: {request.payload}"
                    channel.send(request.user_id, content)
            self.queue.task_done()

Java

Java
import java.util.*;
import java.util.concurrent.*;

class NotificationRequest {
    String requestId;
    String userId;
    String eventType;
    Map<String, String> payload;

    NotificationRequest(String id, String user, String event, Map<String, String> payload) {
        this.requestId = id;
        this.userId = user;
        this.eventType = event;
        this.payload = payload;
    }
}

interface Channel {
    boolean send(String userId, String content);
}

class EmailChannel implements Channel {
    public boolean send(String userId, String content) {
        System.out.println("[Email] Sent to " + userId + ": " + content);
        return true;
    }
}

class SmsChannel implements Channel {
    public boolean send(String userId, String content) {
        System.out.println("[SMS] Sent to " + userId + ": " + content);
        return true;
    }
}

class RetryingChannel implements Channel {
    private final Channel inner;
    private final int maxRetries = 3;

    RetryingChannel(Channel inner) { this.inner = inner; }

    public boolean send(String userId, String content) {
        for (int i = 0; i < maxRetries; i++) {
            try {
                if (inner.send(userId, content)) return true;
            } catch (Exception e) {
                try {
                    Thread.sleep((long) Math.pow(2, i) * 1000);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                }
            }
        }
        return false;
    }
}

class PreferenceStore {
    private final Map<String, List<String>> userPrefs = new ConcurrentHashMap<>();
    
    public List<String> getPreferredChannels(String userId) {
        return userPrefs.getOrDefault(userId, Arrays.asList("email", "sms"));
    }
}

public class NotificationDispatcher {
    private final BlockingQueue<NotificationRequest> queue = new LinkedBlockingQueue<>();
    private final PreferenceStore prefStore;
    private final Map<String, Channel> channels = new HashMap<>();
    private final Set<String> sentRecords = ConcurrentHashMap.newKeySet();
    private final ExecutorService executor = Executors.newSingleThreadExecutor();

    public NotificationDispatcher(PreferenceStore store) {
        this.prefStore = store;
        channels.put("email", new RetryingChannel(new EmailChannel()));
        channels.put("sms", new RetryingChannel(new SmsChannel()));
    }

    public void enqueue(NotificationRequest req) {
        queue.add(req);
    }

    public void start() {
        executor.submit(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    NotificationRequest req = queue.take();
                    List<String> preferred = prefStore.getPreferredChannels(req.userId);
                    for (String chName : preferred) {
                        String dedupKey = req.requestId + ":" + chName;
                        if (sentRecords.add(dedupKey)) { // Atomically add to set
                            Channel channel = channels.get(chName);
                            if (channel != null) {
                                String content = "Event: " + req.eventType + ", Data: " + req.payload;
                                channel.send(req.userId, content);
                            }
                        }
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        });
    }
}

C++

C++
#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
#include <unordered_set>
#include <queue>
#include <mutex>
#include <thread>
#include <chrono>
#include <memory>
#include <cmath>

struct NotificationRequest {
    std::string requestId;
    std::string userId;
    std::string eventType;
    std::unordered_map<std::string, std::string> payload;
};

class Channel {
public:
    virtual ~Channel() = default;
    virtual bool send(const std::string& userId, const std::string& content) = 0;
};

class EmailChannel : public Channel {
public:
    bool send(const std::string& userId, const std::string& content) override {
        std::cout << "[Email] Sent to " << userId << ": " << content << std::endl;
        return true;
    }
};

class SmsChannel : public Channel {
public:
    bool send(const std::string& userId, const std::string& content) override {
        std::cout << "[SMS] Sent to " << userId << ": " << content << std::endl;
        return true;
    }
};

class RetryingChannel : public Channel {
private:
    std::shared_ptr<Channel> inner;
    int maxRetries = 3;
public:
    RetryingChannel(std::shared_ptr<Channel> in) : inner(in) {}
    bool send(const std::string& userId, const std::string& content) override {
        for (int i = 0; i < maxRetries; ++i) {
            try {
                if (inner->send(userId, content)) return true;
            } catch (...) {
                int sleep_sec = static_cast<int>(std::pow(2, i));
                std::this_thread::sleep_for(std::chrono::seconds(sleep_sec));
            }
        }
        return false;
    }
};

class PreferenceStore {
private:
    std::unordered_map<std::string, std::vector<std::string>> preferences;
    std::mutex mtx;
public:
    std::vector<std::string> getPreferredChannels(const std::string& userId) {
        std::lock_guard<std::mutex> lock(mtx);
        auto it = preferences.find(userId);
        if (it != preferences.end()) return it->second;
        return {"email", "sms"};
    }
};

class NotificationDispatcher {
private:
    std::queue<NotificationRequest> requestQueue;
    std::shared_ptr<PreferenceStore> prefStore;
    std::unordered_map<std::string, std::shared_ptr<Channel>> channels;
    std::unordered_set<std::string> sentRecords;
    std::mutex mtx;
    std::thread workerThread;
    bool running = false;

    void processQueue() {
        while (running) {
            NotificationRequest req;
            {
                std::lock_guard<std::mutex> lock(mtx);
                if (requestQueue.empty()) {
                    std::this_thread::sleep_for(std::chrono::milliseconds(100));
                    continue;
                }
                req = requestQueue.front();
                requestQueue.pop();
            }

            auto preferred = prefStore->getPreferredChannels(req.userId);
            for (const auto& chName : preferred) {
                std::string dedupKey = req.requestId + ":" + chName;
                {
                    std::lock_guard<std::mutex> lock(mtx);
                    if (sentRecords.find(dedupKey) != sentRecords.end()) {
                        continue; // Already processed
                    }
                    sentRecords.insert(dedupKey);
                }

                auto it = channels.find(chName);
                if (it != channels.end()) {
                    std::string content = "Event: " + req.eventType;
                    it->second->send(req.userId, content);
                }
            }
        }
    }

public:
    NotificationDispatcher(std::shared_ptr<PreferenceStore> store) : prefStore(store) {
        channels["email"] = std::make_shared<RetryingChannel>(std::make_shared<EmailChannel>());
        channels["sms"] = std::make_shared<RetryingChannel>(std::make_shared<SmsChannel>());
    }

    ~NotificationDispatcher() {
        running = false;
        if (workerThread.joinable()) workerThread.join();
    }

    void enqueue(const NotificationRequest& req) {
        std::lock_guard<std::mutex> lock(mtx);
        requestQueue.push(req);
    }

    void start() {
        running = true;
        workerThread = std::thread(&NotificationDispatcher::processQueue, this);
    }
};

Interactive Quiz

Check yourself0/3 answered

1.

2.

3.


Walk a scenario

Order ships → orders service publishes OrderShipped{user, orderId} → notification service (subscriber) creates request n-7741, persists, enqueues, acks. Worker picks it up: preferences say push + email; template order_shipped renders both shapes; PushChannel (wrapped in retry/ratelimit decorators) sends — FCM times out, retry #2 succeeds → status SENT; email passes through SES adapter → SENT; webhook later marks DELIVERED. Same afternoon, a redelivered queue message replays n-7741 — the sent-record short-circuits both channels. Nobody got two pushes; the orders team never knew any of this happened. That mutual invisibility is the design working.


Q&A


Practice — level up

A notification system is pub/sub fan-out with throttling: one event reaches many subscribers across channels, without spamming or duplicating. These drills isolate fan-out and rate control.

Practice ladder: Fan-out & rate limiting0/4 solved

Climb in order — every rung assumes the one above it. Solve on LeetCode, then tick it here; progress is saved on this device.

Warm-up — drop the duplicate

  1. Allow a message at most once per window — dedupe before you ever notify.

Core — one event, many targets

Fan out, then count to throttle.
  1. Push one event to every subscriber — the Observer fan-out at the system's heart.

  2. Count events in a sliding window — the per-user send throttle.

Stretch — batch a window

  1. Aggregate events over the last N minutes — collapsing many notifications into one digest.