Notification System
Multi-channel notification delivery with user preferences and templated messages. Strategy pattern selects the channel, Template Method standardizes formatting, and Observer triggers notifications from domain events.
Key Abstractions
Orchestrator that resolves user preferences, selects channels, renders templates, and dispatches delivery
Strategy interface for delivery. Email, SMS, and Push each implement send() differently.
Message template with named placeholders. Renders final content by substituting context values.
Per-user channel preferences. Controls which channels a user receives notifications on.
Async delivery buffer with retry logic. Decouples notification creation from actual sending.
Class Diagram
The Key Insight
A notification system is not about sending messages. It is about routing the right message through the right channel to the right user at the right time. The core problem is multi-dimensional: you have N notification types, M delivery channels, and every user has their own preferences about which channels they actually want. If you hard-wire any of these dimensions together, you end up with combinatorial explosion in your codebase.
Strategy pattern solves the channel dimension. Each channel (email, SMS, push) implements the same interface but handles delivery through completely different transports. Template Method handles the content dimension. Every notification follows the same structure: resolve placeholders, format for the channel, send. The template defines what varies (the content), while the method defines the fixed flow (render, then deliver). User preferences act as a filter layer between the two, ensuring you only route to channels the user has opted into.
Requirements
Functional
- Support multiple delivery channels: email, SMS, and push notifications
- Template-based message formatting with named placeholders
- Per-user channel preferences that control which channels are active
- Async delivery queue that does not block the caller
- Retry failed deliveries up to a configurable maximum
Non-Functional
- Adding a new channel requires one new class, no modifications to existing code
- Adding a new notification type requires one new template, no code changes
- Channel failures are isolated. A failing SMS gateway must not prevent email delivery.
- Delivery order within a single notification is not guaranteed across channels
Design Decisions
Why Strategy for channels instead of if-else branching?
The naive approach is a switch statement: if email, call SMTP; if SMS, call Twilio; if push, call Firebase. This works for two channels. By the fourth channel, you have a method nobody wants to modify because every change risks breaking the others. Strategy pattern turns each channel into an isolated unit. The orchestrator iterates over channels without knowing what any of them do internally. When product asks for WhatsApp support, you write one class and register it. The orchestrator never changes.
Why templates instead of string formatting in each caller?
Without templates, every place that triggers a notification builds its own message string. You end up with slightly different wording for the same event scattered across the codebase. Templates centralize content. Change the order confirmation wording once and it updates everywhere. The placeholder syntax ({{user_name}}, {{order_id}}) keeps templates readable by non-engineers too, which matters when product wants to tweak copy.
Why a queue instead of direct sending?
Notification delivery involves network calls to external services. SMTP servers timeout. Push gateways rate-limit. If you send synchronously in the request path, a slow email server adds seconds to your API response time. The queue decouples creation from delivery. The caller fires and forgets. A background processor handles the actual sending, retries, and failure tracking.
Why per-user preferences as a first-class concept?
It is tempting to treat preferences as a filter you bolt on later. But preferences affect the core routing decision: which channels get a task. Making UserPreference a first-class object means the service always checks preferences before queuing. There is no code path that accidentally bypasses the check.
Interview Follow-ups
- "How would you add priority levels?" Introduce a priority field on the notification. The queue becomes a priority queue. High-priority notifications (security alerts, password resets) jump ahead of marketing emails.
- "How do you handle rate limiting per channel?" Add a rate limiter per channel type in the queue processor. If the SMS gateway allows 10 messages per second, the queue throttles SMS tasks accordingly while letting email and push proceed at full speed.
- "What about notification grouping or batching?" Add a batching layer before the queue. Collect notifications for the same user within a time window, then merge them into a digest. This prevents the "50 emails in one hour" problem.
- "How would you track delivery status?" Add a status field to NotificationTask (PENDING, DELIVERED, FAILED). Store completed tasks in a database. Expose an API for users to see their notification history and delivery status.
Code Implementation
1 from abc import ABC, abstractmethod
2 from dataclasses import dataclass, field
3 from collections import deque
4 import re
5
6
7 @dataclass
8 class RenderedMessage:
9 """Final message after template rendering."""
10 subject: str
11 body: str
12
13
14 class Template:
15 """Message template with {{placeholder}} syntax.
16 Keeps notification content separate from delivery logic."""
17
18 def __init__(self, name: str, subject_template: str, body_template: str):
19 self.name = name
20 self._subject_template = subject_template
21 self._body_template = body_template
22
23 def render(self, context: dict[str, str]) -> RenderedMessage:
24 subject = self._substitute(self._subject_template, context)
25 body = self._substitute(self._body_template, context)
26 return RenderedMessage(subject=subject, body=body)
27
28 @staticmethod
29 def _substitute(template: str, context: dict[str, str]) -> str:
30 def replacer(match):
31 key = match.group(1).strip()
32 return context.get(key, match.group(0))
33 return re.sub(r"\{\{(.+?)\}\}", replacer, template)
34
35
36 class NotificationChannel(ABC):
37 """Strategy interface. Each channel knows how to deliver a message
38 through its specific transport (SMTP, SMS gateway, push service)."""
39
40 @abstractmethod
41 def get_type(self) -> str: ...
42
43 @abstractmethod
44 def send(self, recipient: str, subject: str, body: str) -> bool: ...
45
46
47 class EmailChannel(NotificationChannel):
48 """Simulates email delivery via SMTP."""
49
50 def get_type(self) -> str:
51 return "email"
52
53 def send(self, recipient: str, subject: str, body: str) -> bool:
54 print(f" [EMAIL] To: {recipient} | Subject: {subject} | Body: {body}")
55 return True
56
57
58 class SMSChannel(NotificationChannel):
59 """Simulates SMS delivery. Subject gets ignored since SMS has no subject line."""
60
61 def get_type(self) -> str:
62 return "sms"
63
64 def send(self, recipient: str, subject: str, body: str) -> bool:
65 print(f" [SMS] To: {recipient} | Body: {body}")
66 return True
67
68
69 class PushChannel(NotificationChannel):
70 """Simulates push notification delivery."""
71
72 def get_type(self) -> str:
73 return "push"
74
75 def send(self, recipient: str, subject: str, body: str) -> bool:
76 print(f" [PUSH] To: {recipient} | Title: {subject} | Body: {body}")
77 return True
78
79
80 @dataclass
81 class UserPreference:
82 """Stores which channels a user has opted into.
83 Defaults to email only if nothing is set."""
84
85 user_id: str
86 enabled_channels: set[str] = field(default_factory=lambda: {"email"})
87
88 def is_enabled(self, channel_type: str) -> bool:
89 return channel_type in self.enabled_channels
90
91
92 @dataclass
93 class NotificationTask:
94 """A single delivery attempt. Tracks retries so we can
95 re-queue on transient failures instead of dropping the message."""
96
97 channel: NotificationChannel
98 recipient: str
99 subject: str
100 body: str
101 retry_count: int = 0
102
103
104 class NotificationQueue:
105 """Async delivery buffer. In production this would sit on top of
106 a real message queue. Here we use a simple deque with retry logic."""
107
108 def __init__(self, max_retries: int = 3):
109 self._pending: deque[NotificationTask] = deque()
110 self._max_retries = max_retries
111 self._delivered: list[NotificationTask] = []
112 self._failed: list[NotificationTask] = []
113
114 def enqueue(self, task: NotificationTask) -> None:
115 self._pending.append(task)
116
117 def process_all(self) -> None:
118 retry_queue: list[NotificationTask] = []
119 while self._pending:
120 task = self._pending.popleft()
121 success = task.channel.send(task.recipient, task.subject, task.body)
122 if success:
123 self._delivered.append(task)
124 elif task.retry_count < self._max_retries:
125 task.retry_count += 1
126 retry_queue.append(task)
127 else:
128 self._failed.append(task)
129 print(f" [FAILED] Giving up on {task.channel.get_type()} "
130 f"to {task.recipient} after {self._max_retries} retries")
131
132 for task in retry_queue:
133 self._pending.append(task)
134
135 @property
136 def delivered_count(self) -> int:
137 return len(self._delivered)
138
139 @property
140 def failed_count(self) -> int:
141 return len(self._failed)
142
143
144 class NotificationService:
145 """Orchestrator. Resolves templates, checks user preferences,
146 and queues delivery tasks. Never sends directly."""
147
148 def __init__(self):
149 self._channels: dict[str, NotificationChannel] = {}
150 self._templates: dict[str, Template] = {}
151 self._preferences: dict[str, UserPreference] = {}
152 self._queue = NotificationQueue(max_retries=3)
153
154 def register_channel(self, channel: NotificationChannel) -> None:
155 self._channels[channel.get_type()] = channel
156
157 def register_template(self, template: Template) -> None:
158 self._templates[template.name] = template
159
160 def set_preference(self, pref: UserPreference) -> None:
161 self._preferences[pref.user_id] = pref
162
163 def send(self, user_id: str, template_name: str, context: dict[str, str]) -> int:
164 """Send notification to user across all their enabled channels.
165 Returns the number of tasks queued."""
166
167 template = self._templates.get(template_name)
168 if template is None:
169 raise ValueError(f"Template '{template_name}' not found")
170
171 pref = self._preferences.get(user_id, UserPreference(user_id=user_id))
172 rendered = template.render(context)
173 queued = 0
174
175 for channel_type, channel in self._channels.items():
176 if pref.is_enabled(channel_type):
177 task = NotificationTask(
178 channel=channel,
179 recipient=user_id,
180 subject=rendered.subject,
181 body=rendered.body,
182 )
183 self._queue.enqueue(task)
184 queued += 1
185
186 return queued
187
188 def flush(self) -> None:
189 """Process all pending notifications."""
190 self._queue.process_all()
191
192 @property
193 def delivered_count(self) -> int:
194 return self._queue.delivered_count
195
196
197 if __name__ == "__main__":
198 service = NotificationService()
199
200 # Register channels
201 service.register_channel(EmailChannel())
202 service.register_channel(SMSChannel())
203 service.register_channel(PushChannel())
204
205 # Register templates
206 service.register_template(Template(
207 name="order_confirmation",
208 subject_template="Order {{order_id}} Confirmed",
209 body_template="Hi {{user_name}}, your order {{order_id}} for {{amount}} has been confirmed.",
210 ))
211 service.register_template(Template(
212 name="password_reset",
213 subject_template="Password Reset Request",
214 body_template="Hi {{user_name}}, click here to reset your password: {{reset_link}}",
215 ))
216 service.register_template(Template(
217 name="welcome",
218 subject_template="Welcome to the platform!",
219 body_template="Hey {{user_name}}, glad to have you. Get started at {{getting_started_link}}.",
220 ))
221
222 # Set user preferences
223 service.set_preference(UserPreference("alice", {"email", "push"}))
224 service.set_preference(UserPreference("bob", {"email", "sms", "push"}))
225 service.set_preference(UserPreference("charlie", {"sms"}))
226
227 # Send order confirmation to Alice (email + push)
228 print("Sending order confirmation to Alice:")
229 queued = service.send("alice", "order_confirmation", {
230 "user_name": "Alice",
231 "order_id": "ORD-1001",
232 "amount": "$79.99",
233 })
234 print(f" Queued {queued} tasks")
235 service.flush()
236
237 # Send password reset to Bob (email + sms + push)
238 print("\nSending password reset to Bob:")
239 queued = service.send("bob", "password_reset", {
240 "user_name": "Bob",
241 "reset_link": "https://example.com/reset/abc123",
242 })
243 print(f" Queued {queued} tasks")
244 service.flush()
245
246 # Send welcome to Charlie (sms only)
247 print("\nSending welcome to Charlie:")
248 queued = service.send("charlie", "welcome", {
249 "user_name": "Charlie",
250 "getting_started_link": "https://example.com/start",
251 })
252 print(f" Queued {queued} tasks")
253 service.flush()
254
255 # Summary
256 print(f"\nTotal delivered: {service.delivered_count}")
257 assert service.delivered_count == 6 # Alice:2 + Bob:3 + Charlie:1
258 print("All assertions passed.")Common Mistakes
- ✗Hardcoding channel logic in the service. Every new channel turns into another if-else branch that nobody wants to touch.
- ✗Skipping user preferences. Sending push notifications to users who disabled them is a fast path to uninstalls.
- ✗Synchronous delivery in the request path. An SMTP timeout should not cause a 500 on your checkout endpoint.
- ✗No retry mechanism. Transient failures in SMS gateways or push services are normal. Dropping notifications silently is not.
Key Points
- ✓Strategy pattern makes channels pluggable. Adding a new channel means one new class, zero changes to the orchestrator.
- ✓Template Method standardizes message formatting. Every channel follows the same render-then-send flow, but each customizes the rendering step.
- ✓User preferences are first-class. Never blast every channel. Respect what the user opted into.
- ✓Async queue with retry handles transient failures. SMTP timeouts and push gateway errors are inevitable.