Courses › Low-Level Design › Problem Walkthroughs
Design a Notification Service
Channels, preferences, templates, retries with backoff and idempotent delivery.
"Design a notification service" asks you to send messages to users over several channels (email, SMS, push, in-app) while respecting their preferences, filling in templates, and retrying failures without sending duplicates. It's a good test of the Strategy and Observer patterns and of failure handling. The system design version, at millions of messages per minute, is a different question. Here the focus is clean, testable classes.
Requirements
- Other code raises a notification by type and recipient, with some data:
notify("order_shipped", user_id, {"order": "A12"}). - Each notification type has a template per channel.
- Users choose which channels they want for each type, and can opt out entirely. Some types (security alerts) ignore opt-outs.
- Sending can fail: retry temporary failures with backoff, and give up on permanent ones.
- The same event must not be delivered twice to the same channel (idempotency).
- Adding a channel (for example, WhatsApp) shouldn't change the core.
Model
Channel (enum) EMAIL, SMS, PUSH
Notification id, type, user_id, data, idempotency_key
Template per (type, channel): render(data) -> Message
Message channel, recipient address, subject, body
PreferenceStore channels_for(user_id, type) -> set[Channel]
Sender (interface) send(message); raises TemporaryError / PermanentError
EmailSender / SmsSender / PushSender
DeliveryLog which (key, channel) pairs were already delivered
NotificationService notify(...): resolve channels -> render -> send with retriesThe flow
[notify(type, user, data)] -> [preferences: which channels?] -> [render template per channel] -> [already delivered?] -> [send with retry] -> [record result]Implementation
import time
from abc import ABC, abstractmethod
from enum import Enum
class Channel(Enum):
EMAIL = "email"
SMS = "sms"
PUSH = "push"
class TemporaryError(Exception): ...
class PermanentError(Exception): ...
class Sender(ABC):
@abstractmethod
def send(self, address, subject, body): ...
class Template:
def __init__(self, subject, body):
self.subject, self.body = subject, body
def render(self, data):
return self.subject.format(**data), self.body.format(**data)
class PreferenceStore:
def __init__(self):
self._prefs = {} # (user_id, type) -> set of channels
self._opted_out = set()
def set(self, user_id, ntype, channels):
self._prefs[(user_id, ntype)] = set(channels)
def opt_out(self, user_id):
self._opted_out.add(user_id)
def channels_for(self, user_id, ntype, default, mandatory):
if user_id in self._opted_out and not mandatory:
return set()
return self._prefs.get((user_id, ntype), default)
class NotificationType:
def __init__(self, name, templates, default_channels, mandatory=False):
self.name, self.templates = name, templates
self.default_channels, self.mandatory = set(default_channels), mandatory
class NotificationService:
def __init__(self, senders, prefs, directory, max_attempts=3, base_delay=0.01,
sleep=time.sleep):
self.senders = senders # Channel -> Sender
self.prefs = prefs
self.directory = directory # user_id -> {Channel: address}
self.types = {}
self.delivered = set() # (idempotency_key, channel)
self.max_attempts, self.base_delay, self.sleep = max_attempts, base_delay, sleep
def register(self, ntype):
self.types[ntype.name] = ntype
def notify(self, type_name, user_id, data, idempotency_key):
ntype = self.types[type_name]
channels = self.prefs.channels_for(user_id, type_name, ntype.default_channels,
ntype.mandatory)
results = {}
for channel in sorted(channels, key=lambda c: c.value):
address = self.directory.get(user_id, {}).get(channel)
template = ntype.templates.get(channel)
if address is None or template is None:
results[channel] = "skipped"
continue
if (idempotency_key, channel) in self.delivered:
results[channel] = "duplicate"
continue
subject, body = template.render(data)
results[channel] = self._send_with_retry(channel, address, subject, body)
if results[channel] == "sent":
self.delivered.add((idempotency_key, channel))
return results
def _send_with_retry(self, channel, address, subject, body):
for attempt in range(self.max_attempts):
try:
self.senders[channel].send(address, subject, body)
return "sent"
except PermanentError:
return "failed"
except TemporaryError:
if attempt + 1 < self.max_attempts:
self.sleep(self.base_delay * 2 ** attempt)
return "failed"A demo with fake senders, which is also how you'd test it:
class FakeSender(Sender):
def __init__(self, fail_times=0, permanent=False):
self.sent, self.fail_times, self.permanent = [], fail_times, permanent
def send(self, address, subject, body):
if self.permanent:
raise PermanentError("bad address")
if self.fail_times > 0:
self.fail_times -= 1
raise TemporaryError("timeout")
self.sent.append((address, subject, body))
email, sms, push = FakeSender(), FakeSender(fail_times=2), FakeSender(permanent=True)
prefs = PreferenceStore()
directory = {"u1": {Channel.EMAIL: "u1@example.com", Channel.SMS: "+15550100",
Channel.PUSH: "device-1"}}
svc = NotificationService({Channel.EMAIL: email, Channel.SMS: sms, Channel.PUSH: push},
prefs, directory, sleep=lambda s: None)
svc.register(NotificationType("order_shipped", {
Channel.EMAIL: Template("Order {order} shipped", "Your order {order} is on its way."),
Channel.SMS: Template("", "Order {order} shipped"),
Channel.PUSH: Template("Shipped", "Order {order} is on its way"),
}, default_channels=[Channel.EMAIL, Channel.SMS, Channel.PUSH]))
first = svc.notify("order_shipped", "u1", {"order": "A12"}, "evt-1")
print({c.value: r for c, r in first.items()})
# {'email': 'sent', 'push': 'failed', 'sms': 'sent'} (SMS succeeded on the third try)
print(svc.notify("order_shipped", "u1", {"order": "A12"}, "evt-1")[Channel.EMAIL]) # duplicate
prefs.set("u1", "order_shipped", [Channel.EMAIL])
prefs.opt_out("u1")
print(svc.notify("order_shipped", "u1", {"order": "B7"}, "evt-2")) # {}
assert len(email.sent) == 1 and len(sms.sent) == 1Design decisions to explain
- One
Senderper channel behind an interface. Adding WhatsApp is a new sender, a new enum value and new templates.NotificationServicedoesn't change. - Templates are data, not code, and keyed by type and channel, because an SMS needs a much shorter message than an email.
- Two kinds of failure. A timeout is worth retrying with exponential backoff; an invalid phone number never will be. Sending the error type through the interface keeps that decision in one place.
- Idempotency by
(event key, channel). If the caller retriesnotifyafter a crash, users don't get the same SMS twice. Record the delivery only after it succeeds. - The sleep function is injected, so tests run instantly and deterministically.
[!WARNING] Don't retry inside the request path in production Retrying with backoff inside notify blocks the caller. In a real system, notify would write a job to a queue and return, and workers would do the sending and retrying. Say this, then keep the synchronous version for the interview unless asked.
Follow-ups
- Asynchronous delivery:
notifyputs jobs on a queue; a worker pool sends them. Failed jobs go back with a delay, and give up into a dead-letter queue after N attempts. - Priorities: security alerts skip ahead of marketing messages. Use separate queues or a priority queue.
- Rate limits and quiet hours: at most N pushes per user per hour; no SMS at night in the user's time zone. These are checks before sending, which fit well as a chain of small policy objects.
- Batching and digests: combine many "someone liked your post" events into one hourly message.
- Fallback channels: if push fails permanently, try email instead.
- Observer integration: domain code publishes
OrderShipped; a listener turns it into anotifycall, so the order service never knows about notifications.
[!TIP] Practice Design a Notification Service · Design an In-Memory Pub/Sub Broker · Design Patterns