Every product needs one, and it is a favourite because it exercises exactly the patterns LLD rounds care about: **strategy for pluggable channels, decorator or chain for cross-cutting rules, and an idempotent retry story.**
Requirements
- Send a notification over email, SMS, push or in-app.
- Templated content with variables, and localisation.
- Per-user preferences: which channels, which categories, quiet hours.
- Retry on transient failure; never send a duplicate.
- Scheduled and batched sends.
- Delivery tracking: sent, delivered, opened, failed.
The core abstraction
Channels differ in transport, credentials and failure modes, but not in purpose:
interface NotificationChannel {
ChannelType type();
DeliveryResult send(Notification n) throws TransientFailure;
boolean supports(Notification n); // e.g. SMS needs a verified phone number
}
class EmailChannel implements NotificationChannel { /* SES / SendGrid */ }
class SmsChannel implements NotificationChannel { /* Twilio */ }
class PushChannel implements NotificationChannel { /* APNs / FCM */ }
class InAppChannel implements NotificationChannel { /* write to a table */ }Strategy, and it is the right call: adding WhatsApp later means adding one class and
registering it, with no change to the dispatcher. Say that explicitly — extensibility is what the interviewer is checking.
The pipeline
Request → Validate
→ Resolve recipient + preferences
→ Filter channels (opted in? quiet hours? category enabled?)
→ Render template per channel and locale
→ Deduplicate (idempotency key)
→ Rate limit per user
→ Enqueue per channel
→ Worker → channel.send() → retry on transient failure
→ Record delivery status; ingest provider webhooksEach stage is small and independently testable — this is where a chain of responsibility
genuinely fits, with each stage able to stop the pipeline (opted out, rate limited,
duplicate) rather than a nested block of ifs in one method.
Preferences
class NotificationPreference {
Map<Category, Set<ChannelType>> enabled; // per category, per channel
TimeRange quietHours; // in the USER's timezone
Frequency digest; // IMMEDIATE | HOURLY | DAILY
}Two rules that are easy to get wrong and worth stating:
- Quiet hours are evaluated in the recipient's timezone, not the server's — a global
product gets this wrong constantly.
- Transactional notifications ignore preferences. A password reset or a payment failure
must send regardless of marketing opt-outs. Model Category with a
isTransactional flag so the distinction lives in the type system rather than in a
comment.
Templates
class Template {
TemplateId id;
ChannelType channel;
Locale locale;
String subject; // email only
String body; // "Hi {{name}}, your order {{orderId}} has shipped"
}Keyed by (templateId, channel, locale) — the same event needs a 160-character SMS and a
rich HTML email, and it needs both in every supported language. Rendering must fail loudly
on a missing variable rather than sending "Hi {{name}}".
Retries and idempotency
class RetryPolicy {
int maxAttempts = 5;
Duration baseDelay = Duration.ofSeconds(1);
Duration nextDelay(int attempt) {
long millis = baseDelay.toMillis() * (1L << attempt); // exponential
return Duration.ofMillis(millis + jitter(millis)); // + jitter
}
}Jitter is not optional. Without it, a provider outage causes every failed notification to
retry in lockstep and re-overwhelm the provider the moment it recovers.
Distinguish transient failures (timeout, 5xx, rate limited → retry) from permanent ones (invalid address, unsubscribed, 4xx → do not retry; mark the address bad). Retrying a permanent failure five times wastes quota and can get you blocked.
After the final attempt, move the message to a dead-letter queue for inspection rather than dropping it.
Deduplication uses an idempotency key — (user, event_id, channel) — stored with a TTL.
A retried upstream request produces the same key and is dropped. This is the single most
important safeguard: duplicate notifications are the failure users actually notice.
Rate limiting and batching
Cap per user per category (see rate limiter) so a misbehaving producer cannot send someone 500 emails. Where the user has chosen a digest frequency, accumulate into a pending bucket and flush on a schedule — one "you have 12 new messages" instead of twelve notifications.
Patterns worth naming
- Strategy — channels. Lead with this.
- Chain of responsibility — the preference/dedup/rate-limit pipeline.
- Template Method — a base channel handling retry and logging, with subclasses
implementing only the transport.
- Observer — delivery-status webhooks updating tracking.
- Builder — constructing a notification with many optional fields.
- Factory — resolving a channel by type.
Edge cases to handle
- User has no email or phone on file → channel
supportsreturns false, fall back. - Provider is down → retry, then fail over to a secondary provider.
- Notification generated during quiet hours → defer to the window's end, do not drop.
- Bounces and unsubscribes fed back from the provider must update preferences.
- Scheduled send for a user who deletes their account first.
Extensions to be ready for
- Channel fallback — push, then SMS if undelivered within N minutes.
- Priority queues — an OTP must not queue behind a marketing batch.
- A/B testing template variants.
- Analytics — open and click tracking per template.
- Multi-tenant — per-tenant providers, quotas and branding.
Track this problem on the System Design sheet.
Frequently asked
How do you support multiple notification channels cleanly?
With the strategy pattern: one NotificationChannel interface implemented by email, SMS, push and in-app, each encapsulating its own transport and failure modes. The dispatcher selects channels from user preferences and calls the same method on each, so adding a new channel later means adding one class and registering it, with no change to the dispatch logic.
How do you prevent duplicate notifications?
With an idempotency key derived from the user, the source event and the channel, stored with a TTL. A retried upstream request produces the same key and is dropped before dispatch. Duplicates are the failure users notice most, so this check belongs in the pipeline ahead of enqueueing rather than being left to the provider.
Should notification preferences apply to every message?
No. Transactional messages — password resets, payment failures, security alerts — must send regardless of marketing opt-outs, so the category should carry a transactional flag that the preference filter respects. Quiet hours must also be evaluated in the recipient's own timezone, and a message generated during them should be deferred rather than dropped.