Notification system
Low-level design of a multi-channel notification system -- email, SMS, push, and in-app channels with user preferences, template rendering, retry on failure, rate limiting per user, and a priority queue for urgent notifications.
The Problem
Your company's backend sends notifications from a dozen different services: order confirmations via email, two-factor codes via SMS, flash-sale alerts via push, and unread-message badges in the app. Each service talks directly to the channel provider (SendGrid, Twilio, Firebase), duplicating retry logic, ignoring user preferences, and occasionally spam-blasting a user with 30 push notifications in an hour because nobody enforces rate limits.
A centralized notification system decouples senders from channels. It accepts a notification request, resolves the user's preferred channels, renders a template, enforces per-user rate limits, routes through a priority queue (so a security alert jumps ahead of a marketing digest), delivers via the correct channel adapter, retries on failure with backoff, and logs the outcome.
Design the core classes for a notification system that supports multi-channel delivery (email, SMS, push, in-app), user preference management, template rendering, per-user rate limiting, priority queuing, retry with backoff, and delivery event callbacks.
Requirements
Clarifying Questions
Before jumping into class design, ask questions to turn the vague prompt into a concrete specification. Cover four areas: core actions, error handling, boundaries, and future extensions.
You: "Which notification channels should we support, and can a single notification go to multiple channels simultaneously?"
Interviewer: "Four channels: email, SMS, push, and in-app. A notification targets one channel per request, but the caller can fan out to multiple channels by submitting multiple requests."
Four channel types. Each channel has a different delivery adapter, which points directly at a Strategy interface.
You: "How granular are user preferences? Per channel? Per notification type? Can users set quiet hours?"
Interviewer: "Users configure opt-in/opt-out per notification type per channel. They can also set quiet hours with a timezone. During quiet hours, non-urgent notifications are deferred."
Per-type, per-channel preferences plus quiet hours. The preference check runs before delivery. Quiet-hours deferral means we need a scheduling mechanism for delayed dispatch.
You: "Should we enforce rate limits? A user probably does not want 50 push notifications in an hour."
Interviewer: "Yes. Each channel has a per-user rate limit: for example, max 10 push notifications per hour. Exceeding the limit silently drops the notification."
Per-user, per-channel rate limiting. A sliding-window or token-bucket counter keyed on (userId, channel).
You: "Are some notifications more urgent than others? Should a security alert skip the queue?"
Interviewer: "Yes. Three priority levels: LOW, NORMAL, URGENT. Urgent notifications bypass rate limits and queue ahead of lower-priority ones."
Priority queue with three levels. Urgent skips rate limiting entirely.
You: "What happens when a channel delivery fails? Retry immediately, backoff, or give up?"
Interviewer: "Retry with exponential backoff. Each channel has a max retry count. After exhausting retries, mark it as failed and notify a callback listener."
Per-channel retry config. Failed deliveries fire an observer callback so upstream services can react.
You: "Do we need a template engine? Or do callers send pre-rendered content?"
Interviewer: "The system owns templates. Callers send a template ID plus a map of variables. The system renders the final subject and body. Each template has channel-specific variants."
Template rendering with variables. Each template supports multiple channel variants (email gets HTML; SMS gets plain text).
You: "Should we track delivery status and expose it to callers?"
Interviewer: "Yes. Every notification has a status: PENDING, SENT, DELIVERED, FAILED. Callers can query status. Success and failure events fire observer callbacks."
Delivery lifecycle with status tracking and observer notifications.
Perfect. You have now clarified scope and ruled out unnecessary complexity.
Final Requirements
Functional Requirements:
send(request)accepts a notification request with userId, templateId, channel, priority, and template variables- Resolve user preferences: skip delivery if the user opted out of that notification type on that channel
- Render the notification body from a template with variable substitution
- Enforce per-user, per-channel rate limits (configurable max per hour); URGENT priority bypasses limits
- Queue notifications by priority: URGENT before NORMAL before LOW
- Deliver via the appropriate channel adapter (email/SMS/push/in-app)
- Retry failed deliveries with exponential backoff, up to a channel-specific max retry count
- Track delivery status (PENDING, SENT, DELIVERED, FAILED) and fire observer callbacks on state changes
Non-Functional Requirements:
- Thread-safe: concurrent sends from multiple services
- Extensible: adding a new channel requires one adapter class, no changes to existing code
- Testable: channel adapters are injected, not hardcoded
Out of Scope: Notification digests/batching, A/B testing, analytics dashboards, persistence layer, UI rendering.
Interview tip
Numbering your requirements makes it easy to reference them later: "This class satisfies requirements 4 and 6." Traceability keeps the design discussion focused.
30-Second Design Summary
NotificationService turns an immutable NotificationRequest into a lifecycle-tracked Notification: resolve preferences, render a template, apply rate limits, enqueue by priority, and deliver through a NotificationChannel. Channel adapters, retry policy, and delivery listeners are separate collaborators. The status transition is authoritative for the notification, while provider callbacks and observers update delivery information without coupling the caller to email, SMS, push, or in-app mechanics.
5-Minute Walkthrough
- Bound the scope. The core handles one notification request at a time, preferences, templates, per-user/channel limits, priority dispatch, retries, and status events. Digests, persistence, analytics, and A/B testing are extensions.
- Validate before enqueueing. Check the request, user preference, template variables, and rate limit. URGENT bypasses the configured limit only if that policy is explicit; it should not bypass invalid content or unavailable channels.
- Create the lifecycle entity. Render once into
Notificationso retries send the same subject/body snapshot instead of observing a changed template or user preference halfway through delivery. - Dispatch by priority. A worker takes URGENT before NORMAL before LOW, calls the selected adapter, and transitions the notification to
SENT,DELIVERED, or retry/failed state according to the result. - Keep integrations replaceable. Channel adapters and listeners are injected. A durable queue, provider callback adapter, or digest processor can be added without moving provider-specific code into the orchestrator.
Example Inputs and Outputs
Scenario 1: Normal delivery with preferences
- Input: Send ORDER_CONFIRMED notification to user U1 on EMAIL channel, NORMAL priority, variables
{orderId: "ORD-123", total: "$49.99"} - Expected: System checks U1's preferences. U1 has email enabled for ORDER_CONFIRMED. Template renders subject "Order ORD-123 confirmed" and HTML body. Rate limit check passes (only 2 emails sent this hour, limit is 20). Email adapter delivers. Status changes from PENDING to SENT. Success callback fires.
Scenario 2: Rate limit exceeded
- Input: Send FLASH_SALE notification to user U2 on PUSH channel, LOW priority
- Expected: U2 has already received 10 push notifications this hour (the configured limit). Priority is LOW, so rate limiting applies. Notification is silently dropped. Status set to FAILED with reason "rate_limit_exceeded". No retry.
Scenario 3: Urgent notification bypasses rate limits and quiet hours
- Input: Send SUSPICIOUS_LOGIN notification to user U3 on SMS channel, URGENT priority. U3 is in quiet hours and has hit the SMS rate limit.
- Expected: URGENT bypasses both quiet hours and rate limits. Template renders "Suspicious login from IP 203.0.113.42". SMS adapter delivers. On success, status moves to SENT. If delivery fails, system retries up to 3 times with exponential backoff.
Try It Yourself
Try it yourself
Before reading the solution, spend 15-20 minutes sketching your own class diagram. Focus on how you separate channel delivery from preference checking and rate limiting. Think about which parts vary (channels, templates) and which parts stay the same (the processing pipeline). Compare your approach with the walkthrough below.
Step 1: Identify Core Entities
Start by asking: what are the main "things" in this problem? Look for nouns in your requirements: notification, channel, user preference, template, rate limit, priority queue, delivery log.
A common mistake is lumping everything into a single NotificationService god class. Good design means each class has a single, clear job.
| Entity | Responsibility | Key attributes |
|---|---|---|
| NotificationRequest | Immutable value object capturing what the caller wants to send | userId, templateId, channel, priority, variables |
| Notification | The lifecycle-tracked entity that moves through the pipeline | id, request, renderedSubject, renderedBody, status, retryCount, createdAt |
| NotificationChannel | Strategy interface for delivering to a specific channel | deliver(notification): DeliveryResult |
| UserPreference | Per-user, per-type, per-channel opt-in/opt-out plus quiet hours | userId, channelPrefs map, quietHours |
| NotificationTemplate | Holds subject and body templates with variable placeholders | id, type, channel, subjectTemplate, bodyTemplate |
| RateLimiter | Tracks per-user, per-channel send counts within a sliding window | allowRequest(userId, channel): boolean |
| NotificationService | The orchestrator. Validates, renders, rate-checks, queues, and dispatches | channels map, rateLimiter, templateEngine, listeners |
| DeliveryListener | Observer callback interface for delivery success/failure events | onSuccess(notification), onFailure(notification, reason) |
Notice that NotificationRequest is separate from Notification. The request is what comes in from the caller (immutable input). The Notification is the enriched, trackable entity that moves through the pipeline and accumulates state (rendered content, delivery status, retry count). Merging them violates SRP because input validation and lifecycle tracking are different concerns.
Step 2: Define Relationships and Class Design
NotificationService (the orchestrator)
This is the central class that owns the processing pipeline. Every notification flows through it.
Deriving state from requirements:
| Requirement | What NotificationService must track |
|---|---|
| "Deliver via appropriate channel adapter" | A map of channel type to adapter |
| "Enforce per-user rate limits" | A RateLimiter instance |
| "Render from templates" | A TemplateEngine instance |
| "Fire observer callbacks on state changes" | A list of DeliveryListener observers |
Deriving methods from needs:
| Need from requirements | Method |
|---|---|
| "Accept a notification request" | send(NotificationRequest request) |
| "Register delivery callbacks" | addListener(DeliveryListener listener) |
Notification (the lifecycle entity)
Each notification tracks its own delivery state. The status enum drives what operations are valid.
Deriving state from requirements:
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with SDEpedia Premium.