STUDY NOTES · LOW-LEVEL DESIGN
Notification service
Every system sends them. Few send them well.
Templates instead of raw text, per-channel senders with provider failover, retries with backoff and jitter, preference filters, idempotency by request ID, and the path to an async pipeline.
“Design a service other teams call to notify users by SMS, email or push” is a very practical SDE2 question, because every product needs it and most get it slightly wrong. The design is a handful of small patterns working together: a strategy per channel, an adapter per vendor, a chain of filters, and retries wrapped around the providers.
// THE SHORT VERSION
- Callers send a template ID + parameters, never raw text.
- Filters as a list: opt-outs, quiet hours, frequency caps. Critical messages always go.
- Retry only retryable errors, with exponential backoff and jitter, then fail over to the next provider.
- Idempotent by request ID, so upstream retries never spam the user.
01
Requirements
send(NotificationRequest): recipient user, template ID + parameters, channels (SMS, EMAIL, PUSH, WHATSAPP), priority.- Templates are rendered with parameters, e.g. “Your order {{orderId}} is out for delivery”.
- Respect user preferences (opted out of promotional SMS) and quiet hours for low-priority messages.
- Retries with backoff on provider failure; fallback to another provider.
- Idempotent: the same request ID is never sent twice.
- Track delivery status per channel.
02
Design
NotificationService
send(NotificationRequest): NotificationResult
NotificationRequest
String requestId
String userId
String templateId
Map params
Set<Channel> channels
Priority priority
ChannelSender (interface)
channel(): Channel
send(Recipient, RenderedMessage): DeliveryResult
SmsSender
EmailSender
PushSender
Provider (interface)
deliver(...)
TemplateEngine
render(templateId, Channel, params): RenderedMessage
PreferenceFilter (interface)
allowed(userId, Channel, Priority): bool
RetryPolicy
execute(Supplier)
How they connect:
SmsSender implements ChannelSender
EmailSender implements ChannelSender
PushSender implements ChannelSender
SmsSender uses Provider (primary + fallback)
NotificationService uses ChannelSender
NotificationService uses TemplateEngine
NotificationService uses PreferenceFilterPatterns:
- Strategy: a
ChannelSenderper channel. - Adapter: a
Providerper vendor (Twilio, Gupshup, SES, FCM). - Chain of Responsibility: the filters (opt-out, quiet hours, frequency cap).
- Decorator: retry and fallback wrapped around providers.
03
Core code
enum Channel { SMS, EMAIL, PUSH, WHATSAPP }
enum Priority { CRITICAL, TRANSACTIONAL, PROMOTIONAL }
enum DeliveryStatus { SENT, FAILED, SKIPPED }
record NotificationRequest(String requestId, String userId, String templateId,
Map<String, String> params, Set<Channel> channels, Priority priority) {}
record RenderedMessage(String subject, String body) {}
record DeliveryResult(Channel channel, DeliveryStatus status, String detail) {}
interface Provider { void deliver(String address, RenderedMessage msg) throws ProviderException; String name(); }
interface ChannelSender {
Channel channel();
DeliveryResult send(User user, RenderedMessage msg);
}
/** Wraps providers in priority order and tries each with retries: Decorator/fallback. */
class FailoverChannelSender implements ChannelSender {
private final Channel channel; private final List<Provider> providers; private final RetryPolicy retry;
private final Function<User, String> addressOf; // user → phone / email / device token
FailoverChannelSender(Channel channel, List<Provider> providers, RetryPolicy retry, Function<User, String> addressOf) {
this.channel = channel; this.providers = providers; this.retry = retry; this.addressOf = addressOf;
}
public Channel channel() { return channel; }
public DeliveryResult send(User user, RenderedMessage msg) {
String address = addressOf.apply(user);
if (address == null) return new DeliveryResult(channel, DeliveryStatus.SKIPPED, "no address");
for (Provider p : providers) {
try {
retry.run(() -> p.deliver(address, msg));
return new DeliveryResult(channel, DeliveryStatus.SENT, p.name());
} catch (ProviderException e) { /* log, try the next provider */ }
}
return new DeliveryResult(channel, DeliveryStatus.FAILED, "all providers failed");
}
}
class RetryPolicy {
private final int maxAttempts; private final Duration baseDelay;
RetryPolicy(int maxAttempts, Duration baseDelay) { this.maxAttempts = maxAttempts; this.baseDelay = baseDelay; }
void run(ThrowingRunnable action) throws ProviderException {
for (int attempt = 1; ; attempt++) {
try { action.run(); return; }
catch (ProviderException e) {
if (!e.isRetryable() || attempt == maxAttempts) throw e;
long backoff = baseDelay.toMillis() * (1L << (attempt - 1));
sleep(ThreadLocalRandom.current().nextLong(backoff / 2, backoff + 1)); // exponential backoff + jitter
}
}
}
private void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } }
}
interface ThrowingRunnable { void run() throws ProviderException; }
interface PreferenceFilter { boolean allowed(User user, Channel channel, Priority priority); }
class OptOutFilter implements PreferenceFilter {
public boolean allowed(User u, Channel c, Priority p) {
return p == Priority.CRITICAL || !u.optedOut(c, p); // critical (OTP, security) always goes out
}
}
class QuietHoursFilter implements PreferenceFilter {
private final Clock clock;
QuietHoursFilter(Clock clock) { this.clock = clock; }
public boolean allowed(User u, Channel c, Priority p) {
if (p != Priority.PROMOTIONAL) return true;
LocalTime t = LocalTime.now(clock.withZone(u.zone()));
return !(t.isAfter(LocalTime.of(22, 0)) || t.isBefore(LocalTime.of(8, 0)));
}
}
class NotificationService {
private final Map<Channel, ChannelSender> senders;
private final List<PreferenceFilter> filters;
private final TemplateEngine templates; private final UserRepository users;
private final Map<String, List<DeliveryResult>> processed = new ConcurrentHashMap<>(); // a DB table in prod
NotificationService(List<ChannelSender> senders, List<PreferenceFilter> filters,
TemplateEngine templates, UserRepository users) {
this.senders = senders.stream().collect(Collectors.toMap(ChannelSender::channel, s -> s));
this.filters = filters; this.templates = templates; this.users = users;
}
public List<DeliveryResult> send(NotificationRequest req) {
// computeIfAbsent: atomic per requestId, so a duplicate concurrent call returns the first result
return processed.computeIfAbsent(req.requestId(), id -> doSend(req));
}
private List<DeliveryResult> doSend(NotificationRequest req) {
User user = users.get(req.userId());
List<DeliveryResult> results = new ArrayList<>();
for (Channel ch : req.channels()) {
if (!filters.stream().allMatch(f -> f.allowed(user, ch, req.priority()))) {
results.add(new DeliveryResult(ch, DeliveryStatus.SKIPPED, "preference/quiet hours"));
continue;
}
RenderedMessage msg = templates.render(req.templateId(), ch, req.params());
results.add(senders.get(ch).send(user, msg));
}
return results;
}
}04
Design decisions to explain
- Callers send a template ID and parameters, not raw text. That keeps content consistent, makes localisation (per-language templates) possible, and lets the content be reviewed for compliance.
- Filters as a list: adding a frequency cap (“no more than 3 promotional pushes a day”) is one new class.
- Separate provider adapters make vendor fallback and cost-based routing possible.
- Retries only on retryable errors (timeouts, 5xx). Never retry on “invalid number”.
- Idempotency by
requestId: the producer (e.g. the order service) usesorderId + statusas the request ID, so retries upstream don’t spam the user.
05
Scaling it (bridge to HLD)
- Make it asynchronous: the API validates and enqueues to Kafka/SQS per priority (OTPs on a separate fast lane, so a promotional campaign can’t delay them). Workers per channel consume and send.
- Rate limits per provider (vendor TPS limits) using a token bucket (see LLD 05).
- Delivery receipts: provider webhooks update the status (
DELIVERED,READ). Store them for analytics. - A DLQ for permanently failed messages, with a replay tool.
- Campaign fan-out (10 million users): a batch job builds the audience, sends in chunks, and throttles so as not to overload providers or your own app servers when users tap through.
Finished reading? Mark it and track your progress on the study page. Saved in this browser only, no sign-up.
→
More in Low-level design
Note 1
The LLD framework
Note 2
Parking lot
Note 3
Food delivery system
Note 4
Splitwise
Note 5
Movie ticket booking
Note 6
Rate limiter
Note 7
Elevator system
Note 8
Vending machine
Note 9
Notification service
You're reading this.
Note 10
Tic-tac-toe
Note 11
Key-value store with TTL
END OF NOTE 9
Up next: Tic-tac-toe
Short, plain-language notes on what interviewers ask an SDE2 and how to think about it.