Design a notification service
The setup
"Build a notification service for a SaaS platform. Users receive notifications via push (mobile), email, SMS, in-app. Notifications include: real-time (order placed), digest (daily summary), reminders (subscription expiring), marketing (broadcasts). 10M users; 100M notifications/day; latency requirements vary by channel."
Clarifying questions
| Question | Story answer |
|---|---|
| What channels? | Email, SMS, mobile push (FCM/APNs), in-app (WebSocket). |
| Per-channel latency targets? | Push: <5 sec. Email: <30 sec. SMS: <30 sec. In-app: <1 sec. |
| Multi-language? | Yes — template translations per locale. |
| User preferences? | Users can disable channels per notification type. |
| Quiet hours? | Yes — no notifications during user's local night. |
| Critical vs marketing? | Priority levels; high-priority bypass quiet hours and rate limits. |
The architecture
Producers (other services) ──API call──► [Notification API]
│
↓
[Kafka topic: notifications.requested]
│
↓
[Notification Dispatcher]
- Look up user preferences
- Apply rate limits / quiet hours
- Determine channels for this notification
- Generate per-channel jobs
│
┌──────────────────────┴──────────────────────┐
↓ ↓ ↓ ↓
[Push topic] [Email topic] [SMS topic] [In-app topic]
│ │ │ │
[Push Worker] [Email Worker] [SMS Worker] [In-app Worker]
│ │ │ │
↓ ↓ ↓ ↓
[FCM/APNs] [SES/SendGrid] [Twilio/MSG91] [WS Gateway]
Design Decision 1: producer decoupling
Producers (the order service, billing service, etc.) shouldn't know about delivery channels. They emit logical notification events: "user X had event Y happen." The notification service handles routing.
Why: adding a new channel (WhatsApp business API) shouldn't require modifying 50 producers. The service in the middle absorbs the channel knowledge.
POST /notifications
{
"user_id": "u123",
"type": "order.placed",
"data": {
"order_id": "o456",
"amount": 1500
},
"priority": "high"
}
// Notification service decides: which channels, what content, what timing.
Design Decision 2: templating
Notifications need text. Should be templated, not hardcoded in producers.
Template "order.placed":
push.en: "Your order #{order_id} for ₹{amount} has been placed"
push.es: "Tu pedido #{order_id} por ₹{amount} ha sido realizado"
email.en:
subject: "Order #{order_id} confirmed"
body: "Hello {first_name}, your order for ₹{amount} ..."
sms.en: "Order #{order_id} placed. ₹{amount}. View: {tracking_url}"
Templates per (notification_type, channel, locale). Variables come from the event payload. Stored in a template registry; cached aggressively (1-hour TTL, invalidate on edit).
Design Decision 3: user preferences
Users opt out of marketing emails, prefer push to SMS, have specific channels disabled. Lookup per notification.
user_preferences:
user_id, notification_type, channel, enabled
e.g., (u123, marketing, email, false)
(u123, marketing, push, true)
(u123, order_updates, email, true)
...
Dispatcher logic for incoming notification:
for each channel in [push, email, sms, in_app]:
if user_preferences[user_id, notification_type, channel] == enabled:
enqueue to channel-specific topic
Preference lookups should be cached (Redis): user preferences change rarely; reads are frequent.
Design Decision 4: digest notifications
Cross-examination round 1: rate limiting per user
Cross-examination round 2: delivery failures
A: Yes — for high-volume use cases. Primary provider (cheaper) handles 95%; fallback (more expensive) for resilience. Worker tracks per-provider success rate; auto-switches based on health. SMS providers (Twilio, MessageBird, MSG91) have very different reliability profiles; multi-provider is standard at scale.
Takeaway
Notification services are the canonical multi-channel fanout problem with policy-driven routing. The architectural skill is in decoupling: producers don't know about channels; channels don't know about producers; the service in the middle handles policies (preferences, rate limits, quiet hours), retries, and routing. This decoupling is what enables adding new channels (WhatsApp Business, Slack DMs, etc.) without modifying producers.
The deeper pattern: any time you have N producers × M consumers, put a smart router in the middle. The router absorbs the cross-product complexity. Without it, each producer has to know about each consumer (N × M coupling); with it, N + M coupling.