LLD Cohort · Week 7 · Class 2 · recording 2h42m

Notification System & URL Shortener

Two LLD problems designed together with the class. The first is a Notification System: Strategy-based dispatching, a channel Factory, a Builder for the message, a repository for user preferences, async fan-out with a thread pool, and retry with the Decorator pattern as the named extension. The second is a URL Shortener: Strategy for code generation and Observer for click tracking. (The DSA hour at the start of the recording, 0:00 – 1:10, is not covered in these notes.)

Each topic: one-line idea → the problem → real-life picture → the design step by step → Python code → doubts & corrections → remember

LLD: Notification System ▶ 1:11:00 – 2:21:33

Source systems call a NotificationService facade. It looks up the user's channel preferences in a repository, and a dispatch Strategy fans the message out to channel objects (SMS, Email, WhatsApp) built by a Factory, either synchronously or asynchronously on a thread pool.

The problem: first principles

"In unknown territory, start from first principles: do you actually know what a notification system is?" It is a service (often SaaS, like Firebase or Apple push) with two sides:

Real-life picture

You place an order and get the same update by SMS, WhatsApp and email. One system takes a single "order placed" event and fans it out to every channel you have enabled. During the previous class a government emergency alert reached everyone's phone at once, which is a broadcast notification.

Requirements

Functional (be descriptive!)Non-functional
Support multiple channels: SMS, Email, WhatsApp, push, …At-least-once delivery.
Support per-user preferences (e.g. "no SMS for me").Thread-safe: many sources hit the system at once.
Support notification types: transactional, promotional, info/communication.Accept notifications from multiple sources asynchronously (a queue / thread pool).
Support notification priority: an OTP must go out in milliseconds, a promo can wait a minute.Extensible: new channels, new dispatch strategies.
Support scheduling (send at time T).
Out of scope, if time permits: retry using the Decorator pattern, error handling, persistence.
Interview tip

"It should notify users" is too vague, because obviously a notification system notifies. Write full sentences that start with "The system should…". Only push things out of scope if you really cannot build them in the time. If you can build something, keep it in and show the skill.

Core entities

Common mistake:

Making user preferences a bare HashMap<userId, Set<Channel>> inside the service "shows immaturity of design". Wrap it in a Repository class, even if it holds a dict internally. It tells the interviewer you know that in production this is a database.

The flow (the diagram to remember)

Source → NotificationService → (UserPreferenceRepository) → Dispatcher → Channels. Each channel owns its own queuing and sending logic, because the email provider, the SMS gateway and WhatsApp are different systems. A channel can keep separate queues (high-priority, normal, scheduled) and poll them on a ticker, for example every 100 ms. For this design, scheduling is just a queue, not a separate strategy.

Python code

Python
import itertools
from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor, wait
from dataclasses import dataclass
from enum import Enum
from typing import Optional

# ---------- enums ----------
class NotificationType(Enum):
    TRANSACTIONAL = "TRANSACTIONAL"
    PROMOTIONAL = "PROMOTIONAL"
    INFO = "INFO"

class Priority(Enum):
    LOW = 1
    MEDIUM = 2
    HIGH = 3

# ---------- the message (many optional fields -> Builder) ----------
@dataclass(frozen=True)
class Notification:
    id: int
    message: str
    user_id: Optional[str] = None        # promotional broadcasts may have no user
    type: NotificationType = NotificationType.INFO
    priority: Priority = Priority.MEDIUM
    scheduled_at: Optional[float] = None  # epoch seconds; None = send now

class NotificationBuilder:
    _ids = itertools.count(1)

    def __init__(self, message: str):
        self._fields = {"message": message}

    def to(self, user_id: str):
        self._fields["user_id"] = user_id
        return self

    def of_type(self, t: NotificationType):
        self._fields["type"] = t
        return self

    def with_priority(self, p: Priority):
        self._fields["priority"] = p
        return self

    def build(self) -> Notification:
        return Notification(id=next(self._ids), **self._fields)

# ---------- channels ----------
class NotificationChannel(ABC):
    @abstractmethod
    def send_notification(self, n: Notification) -> None: ...

class SmsChannel(NotificationChannel):
    def send_notification(self, n):
        print(f"[SMS]   to {n.user_id}: {n.message}")

class EmailChannel(NotificationChannel):
    def send_notification(self, n):
        print(f"[EMAIL] to {n.user_id}: {n.message}")

class WhatsAppChannel(NotificationChannel):
    def send_notification(self, n):
        print(f"[WA]    to {n.user_id}: {n.message}")

class NotificationChannelFactory:
    """One place that knows how to create every channel."""
    _registry = {"SMS": SmsChannel, "EMAIL": EmailChannel, "WHATSAPP": WhatsAppChannel}

    @classmethod
    def get(cls, name: str) -> NotificationChannel:
        return cls._registry[name]()

# ---------- repository (not a bare dict inside the service) ----------
class UserPreferenceRepository:
    def __init__(self):
        self._prefs: dict[str, set[str]] = {}    # stands in for a DB table

    def save(self, user_id: str, channels: set[str]) -> None:
        self._prefs[user_id] = channels

    def get(self, user_id: str) -> list[NotificationChannel]:
        names = sorted(self._prefs.get(user_id, {"EMAIL"}))   # default: email
        return [NotificationChannelFactory.get(name) for name in names]

# ---------- dispatching strategy (the fan-out) ----------
class NotificationDispatchStrategy(ABC):
    @abstractmethod
    def dispatch(self, n: Notification, channels: list[NotificationChannel]) -> None: ...

class SyncDispatcher(NotificationDispatchStrategy):
    def dispatch(self, n, channels):
        for channel in channels:          # waits for each channel before the next
            channel.send_notification(n)

class AsyncDispatcher(NotificationDispatchStrategy):
    def __init__(self, workers: int = 5):
        self._pool = ThreadPoolExecutor(max_workers=workers)   # Java: Executors.newFixedThreadPool(5)

    def dispatch(self, n, channels):
        futures = [self._pool.submit(c.send_notification, n) for c in channels]
        wait(futures)                     # demo only; a real service would just return

# ---------- facade the source systems call ----------
class NotificationService:
    def __init__(self, prefs: UserPreferenceRepository,
                 strategy: NotificationDispatchStrategy):
        self.prefs, self.strategy = prefs, strategy

    def send_notification(self, n: Notification) -> None:
        channels = self.prefs.get(n.user_id)          # 1) who wants what
        self.strategy.dispatch(n, channels)           # 2) fan out

# usage
repo = UserPreferenceRepository()
repo.save("u1", {"SMS", "EMAIL"})
service = NotificationService(repo, AsyncDispatcher())
otp = (NotificationBuilder("Your OTP is 4821").to("u1")
       .of_type(NotificationType.TRANSACTIONAL).with_priority(Priority.HIGH).build())
service.send_notification(otp)
# [EMAIL] to u1: Your OTP is 4821
# [SMS]   to u1: Your OTP is 4821      (order may vary - they run in parallel)

Sync vs async dispatch

Synchronous: loop over the channels and call channel.send_notification() on each. If SMS takes 1 s, WhatsApp waits for it, then email waits for both. Across 5 channels and millions of users, that adds up. Asynchronous: create a fixed thread pool once and submit one task per channel. "It's a two-line change, but you have to know where to use it."

Java vs Python

Java: ExecutorService pool = Executors.newFixedThreadPool(5); pool.submit(() -> channel.sendNotification(n));. Python: ThreadPoolExecutor(max_workers=5).submit(channel.send_notification, n). Both return a Future. If the interviewer explicitly asks you to build a thread pool, you can't use the library one, but for "make it async" in an LLD round the library executor is the right answer.

UML

classDiagram
  class NotificationService {
    +send_notification(n)
  }
  class UserPreferenceRepository {
    +save(user_id, channels)
    +get(user_id) channels
  }
  class NotificationDispatchStrategy {
    +dispatch(n, channels)
  }
  class SyncDispatcher
  class AsyncDispatcher {
    +pool
  }
  class NotificationChannel {
    +send_notification(n)
  }
  class SmsChannel
  class EmailChannel
  class WhatsAppChannel
  class NotificationChannelFactory {
    +get(name) NotificationChannel
  }
  class Notification {
    +id
    +message
    +user_id
    +type
    +priority
    +scheduled_at
  }
  class NotificationBuilder {
    +to(user_id)
    +of_type(t)
    +with_priority(p)
    +build() Notification
  }
  NotificationService --> UserPreferenceRepository
  NotificationService --> NotificationDispatchStrategy
  SyncDispatcher ..|> NotificationDispatchStrategy
  AsyncDispatcher ..|> NotificationDispatchStrategy
  NotificationDispatchStrategy ..> NotificationChannel
  SmsChannel ..|> NotificationChannel
  EmailChannel ..|> NotificationChannel
  WhatsAppChannel ..|> NotificationChannel
  UserPreferenceRepository ..> NotificationChannelFactory
  NotificationChannelFactory ..> NotificationChannel
  NotificationBuilder ..> Notification

Design patterns used and why

PatternWhereWhy
StrategyNotificationDispatchStrategySwap sync, async or load-balanced dispatchers without touching the service.
FactoryNotificationChannelFactoryOne place that knows how to create every channel type.
BuilderNotificationBuilderMany optional fields (user, priority, schedule time). Pass what you have and skip what you don't.
FacadeNotificationServiceSources see one simple API.
RepositoryUserPreferenceRepositorySignals "this is a DB in production".
DecoratorRetryingChannel (extension below)Adds retries to any channel without changing it.

The teacher added several of these on purpose: "to show we are good with LLD, that we are not just thinking randomly". The requirements are brief, so visible patterns and extensibility are how you stand out.

Interview behaviour tips from this section

Doubt: Shouldn't the channel own the dispatching strategy (scheduled / on-demand / priority), rather than the dispatcher owning channels?
→

This was a long and useful debate. In the teacher's design the dispatcher is an orchestrator. It takes the user's preferred channels and sends the notification to each one. Priority and scheduling then live inside each channel as queues: high-priority, normal and scheduled, polled on a ticker. So "scheduled" is not a strategy, it is just a queue. Making it per-channel is a valid extension. LLD is subjective; what matters is that you can defend your choice.

Doubt: Who is the client here, my phone?
→

No. The client (better called the source) is the back-end system that wants something sent, such as Zomato, a bank or the government. Phones and users are the receivers/subscribers.

Doubt: What does "dispatcher" mean?
→

The component that releases or sends things out ("your order has been dispatched" = it left the warehouse). It is a manager that decides where each notification goes. It fans out, which means it spreads one message to many channels.

Doubt: Can two threads talk to each other?
→

Yes, through a shared buffer (the producer–consumer problem): one thread produces, another consumes, and they coordinate with wait/notify ("I'm done, wake up"). Real message passing across processes needs sockets or WebSockets, which is a chat-application problem.

Key takeaways
  • Source → Service → Preferences repo → Dispatcher → Channels. Each channel owns its own queues and provider logic.
  • Strategy for dispatch, Factory for channels, Builder for the notification, Repository for preferences.
  • Async fan-out = submit one task per channel to a fixed thread pool.
  • Priority and scheduling are queues inside a channel, polled on a tick.
  • Name the retry Decorator as an extension even if you don't build it.

Extension: Retry with the Decorator pattern ▶ 1:22:54 – 1:23:05 (mentioned), promised at end of c10

Wrap any NotificationChannel in a RetryingChannel that has the same interface and retries the inner channel's send_notification with backoff.

Where it came from. A student suggested retry as an out-of-scope item: "a good thing, but implementing might take a lot of time". The teacher approved and suggested the line to use in the interview: "I know how to retry using the Decorator design pattern. I'll complete it if I have time." At the end of the previous class he had also listed "how to add a retry mechanism using decorator" as part of this lesson. It was not coded in the recording, so the version below is a minimal sketch of that idea.

Real-life picture

A courier who rings the bell, and if nobody answers, comes back later, up to three times. The parcel and address are unchanged. You have only wrapped the delivery in "try again" behaviour.

Requirements

Python code

Python
# Extension (named in class as out-of-scope): retry with the Decorator pattern.
# Uses NotificationChannel / Notification / EmailChannel from the code above.
import time

class RetryingChannel(NotificationChannel):
    """Wraps ANY channel and adds retries. Same interface, extra behaviour."""

    def __init__(self, inner: NotificationChannel, attempts: int = 3, backoff: float = 0.1):
        self.inner, self.attempts, self.backoff = inner, attempts, backoff

    def send_notification(self, n: Notification) -> None:
        for attempt in range(1, self.attempts + 1):
            try:
                return self.inner.send_notification(n)      # delegate to the real channel
            except Exception as err:
                print(f"attempt {attempt} failed: {err}")
                if attempt == self.attempts:
                    raise                                    # give up: caller decides (DLQ, log...)
                time.sleep(self.backoff * 2 ** (attempt - 1))  # exponential backoff

class FlakyEmail(EmailChannel):          # fails twice, then works - to see the retry
    calls = 0
    def send_notification(self, n):
        FlakyEmail.calls += 1
        if FlakyEmail.calls < 3:
            raise ConnectionError("SMTP timeout")
        super().send_notification(n)

# usage
RetryingChannel(FlakyEmail()).send_notification(NotificationBuilder("Hi").to("u1").build())
# attempt 1 failed: SMTP timeout
# attempt 2 failed: SMTP timeout
# [EMAIL] to u1: Hi

UML

classDiagram
  class NotificationChannel {
    +send_notification(n)
  }
  class EmailChannel
  class SmsChannel
  class RetryingChannel {
    +inner
    +attempts
    +backoff
    +send_notification(n)
  }
  EmailChannel ..|> NotificationChannel
  SmsChannel ..|> NotificationChannel
  RetryingChannel ..|> NotificationChannel
  RetryingChannel o-- NotificationChannel
Why Decorator, not inheritance

A subclass per channel (RetryingSms, RetryingEmail, …) multiplies classes. A decorator is a channel and has a channel, so one class adds retry to all channels. You can also stack decorators, for example LoggingChannel(RetryingChannel(SmsChannel())). The NotificationChannelFactory is the natural place to apply the wrapping.

Pitfall:

Retry plus at-least-once delivery means a user may receive a message twice if the first attempt actually succeeded but timed out. Use an idempotency key (for example the notification id) at the provider when duplicates matter. The teacher plans to cover idempotency with the booking system.

Key takeaways
  • Decorator = same interface + wraps an instance + adds behaviour before or after delegating.
  • Retry with exponential backoff and a maximum number of attempts, then fail loudly.
  • Even saying the pattern's name earns points when you are short of time.

LLD: URL Shortener ▶ 2:21:33 – 2:40:30

A UrlShortenerService turns a long URL into a short code with a pluggable Strategy (hash or Base62), stores a UrlMapping in a repository, resolves codes back to long URLs (an HTTP 302 redirect), and notifies Observers on every click.

The problem

This is a favourite because it can be asked as both HLD and LLD. The teacher did a live demo with bit.ly. A long document URL went in, and a short link like bit.ly/AbC12xy came back: the domain plus a short, unique, hash-like code (about 7 characters). Opening the short link returned 302 Found, a redirect to the long URL. (304 Not Modified, which also showed up in the network tab, is just browser caching.)

Real-life picture

A coat-check counter. You hand over a big coat (the long URL) and get a small token (the short code). Show the token later and you get the coat back. The counter can also count how many times each token was used.

Requirements

FunctionalNon-functional
Take a long URL and return a short URL.Short codes are unique.
Resolve a short URL back to the long URL (redirect).Low latency on resolve.
Keep track of the number of clicks (observability).Support multiple long→short algorithms (extensibility).
Users should not abuse the system. Rate limiting can be mentioned, but it is a whole separate problem, so don't let the interview drift into it.

Core entities

What is Base62?

The teacher named it but did not get to explain it. Give every new URL a unique integer id (from a counter or DB sequence) and write that number in base 62 using 0-9a-zA-Z. Seven Base62 characters give 627 ≈ 3.5 trillion codes. Unique ids mean no collisions, unlike truncating a hash, where two URLs can share the first 7 characters and you have to check and retry.

Python code

Python
import hashlib
import itertools
import threading
from abc import ABC, abstractmethod
from dataclasses import dataclass

# ---------- the unit of the system ----------
@dataclass
class UrlMapping:                      # an object, not just a dict entry
    short_code: str
    long_url: str
    click_count: int = 0

class UrlRepository:                   # stands in for the DB
    def __init__(self):
        self._by_code: dict[str, UrlMapping] = {}
        self._by_long: dict[str, UrlMapping] = {}

    def save(self, m: UrlMapping) -> None:
        self._by_code[m.short_code] = m
        self._by_long[m.long_url] = m

    def by_code(self, code: str) -> UrlMapping | None:
        return self._by_code.get(code)

    def by_long(self, url: str) -> UrlMapping | None:
        return self._by_long.get(url)

# ---------- Strategy: how to make a short code ----------
class ShortCodeStrategy(ABC):
    @abstractmethod
    def generate(self, long_url: str) -> str: ...

class HashStrategy(ShortCodeStrategy):
    def generate(self, long_url: str) -> str:
        return hashlib.md5(long_url.encode()).hexdigest()[:7]   # collisions possible: must check

class Base62Strategy(ShortCodeStrategy):
    ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

    def __init__(self, start: int = 100_000):
        self._counter = itertools.count(start)   # unique id per URL (a DB sequence in real life)
        self._lock = threading.Lock()

    def generate(self, long_url: str) -> str:
        with self._lock:
            n = next(self._counter)
        code = ""
        while n:
            n, r = divmod(n, 62)
            code = self.ALPHABET[r] + code
        return code

# ---------- Observer: who cares about clicks ----------
class ClickObserver(ABC):
    @abstractmethod
    def notify(self, mapping: UrlMapping) -> None: ...

class ClickCountObserver(ClickObserver):
    def notify(self, mapping):
        mapping.click_count += 1

class AnalyticsObserver(ClickObserver):
    def notify(self, mapping):
        print(f"analytics: {mapping.short_code} clicked")

# ---------- orchestrator ----------
class UrlShortenerService:
    BASE = "https://sho.rt/"

    def __init__(self, repo: UrlRepository, strategy: ShortCodeStrategy):
        self.repo, self.strategy = repo, strategy
        self.observers: list[ClickObserver] = []

    def subscribe(self, o: ClickObserver) -> None:
        self.observers.append(o)

    def shorten(self, long_url: str) -> str:
        existing = self.repo.by_long(long_url)
        if existing:                                  # same long URL -> same short URL
            return self.BASE + existing.short_code
        code = self.strategy.generate(long_url)
        self.repo.save(UrlMapping(code, long_url))
        return self.BASE + code

    def resolve(self, short_code: str) -> str:
        m = self.repo.by_code(short_code)
        if m is None:
            raise KeyError("404 - unknown short code")
        for o in self.observers:                      # fan the click event out
            o.notify(m)
        return m.long_url                             # the web layer answers 302 Location: long_url

# usage
svc = UrlShortenerService(UrlRepository(), Base62Strategy())
svc.subscribe(ClickCountObserver())
svc.subscribe(AnalyticsObserver())
short = svc.shorten("https://docs.example.com/weekend-plan?id=42")
print(short)                                          # https://sho.rt/q0U
code = short.rsplit("/", 1)[1]
print(svc.resolve(code))                              # analytics: q0U clicked / long URL
print(svc.repo.by_code(code).click_count)             # 1

UML

classDiagram
  class UrlShortenerService {
    +observers
    +shorten(long_url) str
    +resolve(short_code) str
    +subscribe(observer)
  }
  class UrlRepository {
    +save(mapping)
    +by_code(code) UrlMapping
    +by_long(url) UrlMapping
  }
  class UrlMapping {
    +short_code
    +long_url
    +click_count
  }
  class ShortCodeStrategy {
    +generate(long_url) str
  }
  class HashStrategy
  class Base62Strategy
  class ClickObserver {
    +notify(mapping)
  }
  class ClickCountObserver
  class AnalyticsObserver
  UrlShortenerService --> UrlRepository
  UrlShortenerService --> ShortCodeStrategy
  UrlShortenerService o-- ClickObserver
  UrlRepository o-- UrlMapping
  HashStrategy ..|> ShortCodeStrategy
  Base62Strategy ..|> ShortCodeStrategy
  ClickCountObserver ..|> ClickObserver
  AnalyticsObserver ..|> ClickObserver

Design patterns used and why

Follow-ups and extensions

Homework

Finish the code for Parking Lot, Elevator System, Notification System and URL Shortener. Read about a booking system and write its functional and non-functional requirements (next class: double booking, idempotency, concurrency). Mock interviews start next week.

Key takeaways
  • Two core operations: shorten (long → code) and resolve (code → long, served as a 302 redirect).
  • Model UrlMapping as an object (code, long URL, clicks) and keep the map in a repository.
  • Code generation = Strategy. Base62 of a unique id avoids collisions.
  • Click tracking = Observer, notified on every resolve.