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 retries

The 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) == 1

Design decisions to explain

  • One Sender per channel behind an interface. Adding WhatsApp is a new sender, a new enum value and new templates. NotificationService doesn'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 retries notify after 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: notify puts 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 a notify call, so the order service never knows about notifications.

[!TIP] Practice Design a Notification Service · Design an In-Memory Pub/Sub Broker · Design Patterns