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:
- Sources / clients: back-end systems such as Zomato or a bank. They ask the service to send something. ("Client" confused the class, so the teacher renamed it source.)
- Receivers / subscribers: us, the users, who get the SMS, email or WhatsApp message.
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. |
"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
- Notification: id, message/content, optional user id (a promo broadcast may have none), type, priority, "should be scheduled" plus a time. It has many optional fields, so build it with a Builder.
- Enums:
NotificationType(TRANSACTIONAL, PROMOTIONAL, INFO),Priority(LOW, MEDIUM, HIGH). - NotificationChannel (interface,
send_notification(n)) with SMS / Email / WhatsApp implementations, created through a NotificationChannelFactory. - UserPreferenceRepository: user id → preferred channels.
- NotificationDispatchStrategy (the dispatcher): takes a notification and a set of channels and fans out to them.
- NotificationService: the facade the sources call. It has
send_notification()and, optionally, a separateschedule_notification(). Two simple APIs are easier to use, but one API that decides internally is also fine.
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
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: 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
| Pattern | Where | Why |
|---|---|---|
| Strategy | NotificationDispatchStrategy | Swap sync, async or load-balanced dispatchers without touching the service. |
| Factory | NotificationChannelFactory | One place that knows how to create every channel type. |
| Builder | NotificationBuilder | Many optional fields (user, priority, schedule time). Pass what you have and skip what you don't. |
| Facade | NotificationService | Sources see one simple API. |
| Repository | UserPreferenceRepository | Signals "this is a DB in production". |
| Decorator | RetryingChannel (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
- In a 45-minute LLD round, nobody expects fully running code with constructors. Write the classes, interfaces and key methods. A machine-coding round (around 1.5 h) is different: there, make it run.
- If you are stuck, say "I'll come back to this" and keep moving. Sitting idle fails the round.
- Don't over-optimise one algorithm before the basic design is done, unless the interviewer pinpoints it.
- Multithreading basics to revise: creating a thread, Runnable, passing an object to a Thread, wait/notify, and ExecutorService/thread pools. "If you apply multithreading well in an LLD interview, you're ahead of 99%."
→
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.
→
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.
→
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.
→
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.
- 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.
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
- Retry a failed send up to N times, with exponential backoff (0.1 s, 0.2 s, 0.4 s, …).
- Must work for any channel without editing SMS, Email or WhatsApp code (Open/Closed principle).
- After the last attempt, surface the failure (log it, or push to a dead-letter queue).
Python code
# 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: HiUML
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
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.
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.
- 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.)
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
| Functional | Non-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
- UrlMapping: short code, long URL, click count. It is the "unit" of the system, an object and not a hash map.
- UrlRepository: the hash map from short code to UrlMapping, standing in for the DB.
- ShortCodeStrategy (interface) with HashStrategy and Base62Strategy.
- UrlShortenerService: the orchestrator. It has
shorten(long)andresolve(short)and holds the strategy, so you can plug and play (a student's suggestion). - Observer interface with
notify(), implemented by ClickCountObserver and an analytics observer that might write to an analytics repository.
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
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) # 1UML
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
- Strategy: how to generate codes (hash, Base62, custom alias). The service can switch algorithms.
- Observer: every resolve fires a click event. Click counting, analytics or a data team can subscribe without the service knowing them.
- Repository: the map is the DB in disguise.
- "Strategy + Observer + separation of concerns: do this right and you pass the LLD round." Add these extensions when the core code feels too short.
Follow-ups and extensions
- Collision handling for hash-based codes, and custom aliases.
- Expiry and deletion of links, plus QR codes (bit.ly offers both).
- Rate limiting against abuse (separate problem).
- HLD side: read-heavy traffic → caching. The teacher recommends Designing Data-Intensive Applications and Alex Xu's System Design Interview (vol. 1 has a URL-shortener chapter).
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.
- Two core operations:
shorten(long → code) andresolve(code → long, served as a 302 redirect). - Model
UrlMappingas 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.