Requirements & Scope
Functional Requirements
- Multi-Channel Delivery: Send notifications via Push (APNs/FCM), SMS (Twilio), Email (SES), In-App
- Template System: Reusable templates with variable substitution, i18n (multi-language)
- User Preferences: Opt-in/out per channel, quiet hours, frequency caps, topic subscriptions
- Delivery Pipeline: Create → Validate → Enrich → Route → Send → Track
- Retry & Reliability: Exponential backoff retries, dead letter queue for failed messages
- Rate Limiting: Per-user, per-channel, per-template limits
- Delivery Tracking: Track sent, delivered, opened, clicked, failed
Non-Functional Requirements
- Throughput: 1M notifications/minute peak; 100K emails/minute, 500K push/minute
- Latency: < 500ms for priority notifications; < 5s for batch
- Availability: 99.99% for notification creation; 99.9% for delivery
- Ordering: Best-effort within channel; FIFO per user per channel
- Scalability: 100M users, 10B notifications/day
Notification Types
| Type | Use Case | Channel | Priority |
|---|---|---|---|
| Transactional | OTP, order confirmation | Push/SMS/Email | HIGH |
| Marketing | Promotions, newsletters | Email/Push | LOW |
| System Alert | Server errors, security | Email/SMS | CRITICAL |
| Social | Likes, comments, follows | Push/In-App | MEDIUM |
| Reminder | Appointment, payment due | Push/SMS | MEDIUM |
Core Entities
| Entity | Key Fields |
|---|---|
| Notification | id, userId, type, channel, priority, templateId, variables, status, metadata |
| Template | id, name, channel, subject, body, language, variables, version |
| UserPreference | userId, channel, optedIn, quietHoursStart, quietHoursEnd, frequencyCap |
| DeliveryLog | id, notificationId, channel, status, sentAt, deliveredAt, openedAt, failedReason |
| RateLimit | key (userId:channel), count, windowStart, windowEnd |
Multi-Channel Delivery Pipeline
Delivery Pipeline Architecture
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ CREATE │───▶│ VALIDATE │───▶│ ENRICH │───▶│ ROUTE │───▶│ SEND │───▶│ TRACK │
│ │ │ │ │ │ │ │ │ │ │ │
│ API Call │ │ Check │ │ Template │ │ Select │ │ Call │ │ Log │
│ to create│ │ params, │ │ render, │ │ channel │ │ provider │ │ status │
│ notif. │ │ validate │ │ user │ │ adapter │ │ API │ │ updates │
│ │ │ schema │ │ prefs │ │ │ │ │ │ │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
Kafka Topic Validation Template Channel External Delivery
(create) Errors → DLQ Cache (Redis) Selector APIs Tracker
Notification Class
public class Notification {
private String id;
private String userId;
private NotificationType type;
private NotificationChannel channel;
private NotificationPriority priority;
private String templateId;
private Map<String, String> variables;
private String renderedSubject;
private String renderedBody;
private NotificationStatus status;
private Map<String, String> metadata;
private int retryCount;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Notification(String id, String userId, NotificationType type,
NotificationChannel channel, String templateId,
Map<String, String> variables) {
this.id = id;
this.userId = userId;
this.type = type;
this.channel = channel;
this.templateId = templateId;
this.variables = variables;
this.status = NotificationStatus.CREATED;
this.retryCount = 0;
this.metadata = new HashMap<>();
this.createdAt = LocalDateTime.now();
}
public void markSending() { this.status = NotificationStatus.SENDING; }
public void markSent() { this.status = NotificationStatus.SENT; this.updatedAt = LocalDateTime.now(); }
public void markDelivered() { this.status = NotificationStatus.DELIVERED; }
public void markFailed(String reason) {
this.status = NotificationStatus.FAILED;
this.metadata.put("failureReason", reason);
}
public void incrementRetry() { this.retryCount++; }
}
Channel Interface (Strategy Pattern)
public interface NotificationChannel {
NotificationChannelType getType();
DeliveryResult send(Notification notification);
boolean isAvailable();
}
public class PushNotificationChannel implements NotificationChannel {
private final ApnsClient apnsClient;
private final FcmClient fcmClient;
private final DeviceTokenRepository deviceRepo;
@Override
public NotificationChannelType getType() {
return NotificationChannelType.PUSH;
}
@Override
public DeliveryResult send(Notification notification) {
UserDevice device = deviceRepo.findByUserId(notification.getUserId());
if (device == null) {
return DeliveryResult.failure("No device token");
}
PushMessage message = PushMessage.builder()
.title(notification.getRenderedSubject())
.body(notification.getRenderedBody())
.token(device.getToken())
.data(notification.getMetadata())
.build();
try {
if (device.getPlatform() == Platform.IOS) {
apnsClient.send(message);
} else {
fcmClient.send(message);
}
return DeliveryResult.success();
} catch (Exception e) {
return DeliveryResult.failure(e.getMessage());
}
}
@Override
public boolean isAvailable() {
return apnsClient.isConnected() && fcmClient.isConnected();
}
}
public class SmsNotificationChannel implements NotificationChannel {
private final TwilioClient twilioClient;
private final PhoneNumberRepository phoneRepo;
@Override
public NotificationChannelType getType() {
return NotificationChannelType.SMS;
}
@Override
public DeliveryResult send(Notification notification) {
String phoneNumber = phoneRepo.findByUserId(notification.getUserId());
if (phoneNumber == null) {
return DeliveryResult.failure("No phone number");
}
SmsMessage message = new SmsMessage(
phoneNumber,
notification.getRenderedBody()
);
try {
twilioClient.send(message);
return DeliveryResult.success();
} catch (Exception e) {
return DeliveryResult.failure(e.getMessage());
}
}
@Override
public boolean isAvailable() { return true; }
}
public class EmailNotificationChannel implements NotificationChannel {
private final SesClient sesClient;
private final EmailRepository emailRepo;
@Override
public NotificationChannelType getType() {
return NotificationChannelType.EMAIL;
}
@Override
public DeliveryResult send(Notification notification) {
String emailAddress = emailRepo.findByUserId(notification.getUserId());
if (emailAddress == null) {
return DeliveryResult.failure("No email address");
}
EmailMessage message = EmailMessage.builder()
.to(emailAddress)
.subject(notification.getRenderedSubject())
.body(notification.getRenderedBody())
.isHtml(true)
.build();
try {
sesClient.send(message);
return DeliveryResult.success();
} catch (Exception e) {
return DeliveryResult.failure(e.getMessage());
}
}
@Override
public boolean isAvailable() { return true; }
}
public class InAppNotificationChannel implements NotificationChannel {
private final WebSocketSessionManager sessionManager;
private final InAppNotificationRepository inAppRepo;
@Override
public NotificationChannelType getType() {
return NotificationChannelType.IN_APP;
}
@Override
public DeliveryResult send(Notification notification) {
// Store in DB for persistence
InAppNotification inApp = new InAppNotification(notification);
inAppRepo.save(inApp);
// Push via WebSocket if user is online
WebSocketSession session = sessionManager.getSession(notification.getUserId());
if (session != null && session.isOpen()) {
session.sendMessage(new TextMessage(
toJson(Map.of(
"type", "notification",
"id", inApp.getId(),
"title", notification.getRenderedSubject(),
"body", notification.getRenderedBody(),
"timestamp", Instant.now().toString()
))
));
}
return DeliveryResult.success();
}
@Override
public boolean isAvailable() { return true; }
}
NotificationService (Template Method Pattern)
@Service
public class NotificationService {
private final TemplateEngine templateEngine;
private final PreferenceManager preferenceManager;
private final RateLimiter rateLimiter;
private final DeliveryTracker deliveryTracker;
private final Map<NotificationChannelType, NotificationChannel> channels;
private final RetryQueue retryQueue;
private final KafkaTemplate<String, Notification> kafka;
public NotificationService(List<NotificationChannel> channelList) {
this.channels = channelList.stream()
.collect(Collectors.toMap(NotificationChannel::getType, c -> c));
}
// Template Method: common flow with customizable steps
public void sendNotification(NotificationRequest request) {
Notification notification = createNotification(request);
// Step 1: Validate
ValidationResult validation = validate(notification);
if (!validation.isValid()) {
logValidationFailure(notification, validation);
return;
}
// Step 2: Enrich (render template)
enrich(notification);
// Step 3: Check user preferences
if (!shouldSend(notification)) {
logSkipped(notification, "User preference: opted out or quiet hours");
return;
}
// Step 4: Rate limit check
if (rateLimiter.isRateLimited(notification.getUserId(), notification.getChannel())) {
logSkipped(notification, "Rate limited");
return;
}
// Step 5: Route and send
NotificationChannel channel = channels.get(notification.getChannel());
if (channel == null || !channel.isAvailable()) {
retryQueue.enqueue(notification);
return;
}
notification.markSending();
DeliveryResult result = channel.send(notification);
// Step 6: Track result
deliveryTracker.track(notification, result);
if (result.isSuccess()) {
notification.markSent();
} else if (notification.getRetryCount() < MAX_RETRIES) {
notification.incrementRetry();
retryQueue.enqueueWithBackoff(notification);
} else {
notification.markFailed(result.getReason());
deadLetterQueue.enqueue(notification);
}
kafka.send("notification-events", notification.getId(), notification);
}
private Notification createNotification(NotificationRequest request) {
return new Notification(
UUID.randomUUID().toString(),
request.getUserId(),
request.getType(),
request.getChannel(),
request.getTemplateId(),
request.getVariables()
);
}
private ValidationResult validate(Notification notification) {
List<String> errors = new ArrayList<>();
if (notification.getUserId() == null) errors.add("userId required");
if (notification.getTemplateId() == null) errors.add("templateId required");
if (notification.getChannel() == null) errors.add("channel required");
return errors.isEmpty() ? ValidationResult.valid() : ValidationResult.invalid(errors);
}
private void enrich(Notification notification) {
Template template = templateEngine.getTemplate(notification.getTemplateId());
notification.setRenderedSubject(templateEngine.render(template.getSubject(), notification.getVariables()));
notification.setRenderedBody(templateEngine.render(template.getBody(), notification.getVariables()));
}
private boolean shouldSend(Notification notification) {
return preferenceManager.isChannelEnabled(notification.getUserId(), notification.getChannel())
&& !preferenceManager.isInQuietHours(notification.getUserId())
&& !preferenceManager.isOverFrequencyCap(notification.getUserId(), notification.getChannel());
}
}
Retry with Exponential Backoff
@Component
public class RetryQueue {
private final KafkaTemplate<String, Notification> kafka;
private final DeadLetterQueue deadLetterQueue;
private static final int MAX_RETRIES = 5;
private static final long BASE_DELAY_MS = 1000;
private static final double BACKOFF_MULTIPLIER = 2.0;
public void enqueue(Notification notification) {
long delay = calculateBackoff(notification.getRetryCount());
// Send to Kafka with delay header
kafka.send("notification-retry", notification.getId(), notification,
(result, ex) -> {
if (ex != null) {
deadLetterQueue.enqueue(notification);
}
});
}
public void enqueueWithBackoff(Notification notification) {
if (notification.getRetryCount() >= MAX_RETRIES) {
deadLetterQueue.enqueue(notification);
return;
}
enqueue(notification);
}
private long calculateBackoff(int retryCount) {
// Exponential backoff with jitter
long delay = (long) (BASE_DELAY_MS * Math.pow(BACKOFF_MULTIPLIER, retryCount));
long jitter = (long) (Math.random() * delay * 0.1);
return Math.min(delay + jitter, 60000); // cap at 60s
}
}
@Component
public class DeadLetterQueue {
private final DlqRepository dlqRepo;
private final AlertService alertService;
public void enqueue(Notification notification) {
DlqEntry entry = new DlqEntry();
entry.setNotificationId(notification.getId());
entry.setReason(notification.getMetadata().get("failureReason"));
entry.setCreatedAt(LocalDateTime.now());
dlqRepo.save(entry);
// Alert if DLQ threshold exceeded
if (dlqRepo.countRecent(5) > 100) {
alertService.sendAlert("DLQ threshold exceeded: " + dlqRepo.countRecent(5));
}
}
}
User Preferences & Rate Limiting
User Preference Manager
@Service
public class PreferenceManager {
private final PreferenceRepository prefRepo;
private final RedisTemplate<String, Object> redis;
public boolean isChannelEnabled(String userId, NotificationChannelType channel) {
String key = "pref:" + userId;
Map<Object, Object> prefs = redis.opsForHash().entries(key);
if (prefs.isEmpty()) {
// Load from DB and cache
UserPreference dbPref = prefRepo.findByUserIdAndChannel(userId, channel);
if (dbPref == null) return true; // default: enabled
redis.opsForHash().putAll(key, Map.of(
channel.name(), String.valueOf(dbPref.isOptedIn())
));
redis.expire(key, Duration.ofHours(1));
return dbPref.isOptedIn();
}
return Boolean.parseBoolean((String) prefs.get(channel.name()));
}
public boolean isInQuietHours(String userId) {
UserPreference pref = prefRepo.findByUserId(userId);
if (pref == null || pref.getQuietHoursStart() == null) return false;
LocalTime now = LocalTime.now();
LocalTime start = pref.getQuietHoursStart();
LocalTime end = pref.getQuietHoursEnd();
if (start.isBefore(end)) {
return now.isAfter(start) && now.isBefore(end);
} else { // crosses midnight
return now.isAfter(start) || now.isBefore(end);
}
}
public boolean isOverFrequencyCap(String userId, NotificationChannelType channel) {
String key = "freqcap:" + userId + ":" + channel.name();
String countStr = (String) redis.opsForValue().get(key);
int count = countStr != null ? Integer.parseInt(countStr) : 0;
FrequencyCap cap = getCap(channel);
return count >= cap.getMaxPerHour();
}
public void incrementFrequencyCap(String userId, NotificationChannelType channel) {
String key = "freqcap:" + userId + ":" + channel.name();
redis.opsForValue().increment(key);
redis.expire(key, Duration.ofHours(1));
}
private FrequencyCap getCap(NotificationChannelType channel) {
return switch (channel) {
case PUSH -> new FrequencyCap(10, 50); // 10/hour, 50/day
case SMS -> new FrequencyCap(3, 10); // 3/hour, 10/day
case EMAIL -> new FrequencyCap(5, 20); // 5/hour, 20/day
case IN_APP -> new FrequencyCap(30, 200); // 30/hour, 200/day
};
}
public void updatePreference(String userId, NotificationChannelType channel, boolean optedIn) {
prefRepo.upsert(new UserPreference(userId, channel, optedIn));
// Invalidate cache
redis.delete("pref:" + userId);
}
public void setQuietHours(String userId, LocalTime start, LocalTime end) {
UserPreference pref = prefRepo.findByUserId(userId);
pref.setQuietHoursStart(start);
pref.setQuietHoursEnd(end);
prefRepo.save(pref);
redis.delete("pref:" + userId);
}
}
Rate Limiter (Token Bucket)
@Component
public class RateLimiter {
private final RedisTemplate<String, String> redis;
public boolean isRateLimited(String userId, NotificationChannelType channel) {
String key = "ratelimit:" + userId + ":" + channel.name();
String luaScript = "
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = tonumber(redis.call('GET', key) or '0')
if current >= limit then
return 1
else
redis.call('INCR', key)
if current == 0 then
redis.call('EXPIRE', key, window)
end
return 0
end
";
FrequencyCap cap = getCap(channel);
Long result = redis.execute(
new DefaultRedisScript<>(luaScript, Long.class),
List.of(key),
String.valueOf(cap.getMaxPerHour()),
String.valueOf(3600) // 1 hour window
);
return result != null && result == 1L;
}
}
Template Engine
@Service
public class TemplateEngine {
private final TemplateRepository templateRepo;
private final Cache<String, Template> cache;
public Template getTemplate(String templateId) {
return cache.get(templateId, id -> templateRepo.findById(id));
}
public String render(String templateStr, Map<String, String> variables) {
String result = templateStr;
for (Map.Entry<String, String> entry : variables.entrySet()) {
result = result.replace("{{" + entry.getKey() + "}}", entry.getValue());
}
return result;
}
public String renderForLocale(String templateId, String language,
Map<String, String> variables) {
Template template = templateRepo.findByIdAndLanguage(templateId, language);
if (template == null) {
// Fallback to default language
template = getTemplate(templateId);
}
return render(template.getBody(), variables);
}
}
// Template entity
public class Template {
private String id;
private String name;
private NotificationChannelType channel;
private String subject;
private String body;
private String language;
private List<String> requiredVariables;
private int version;
private boolean active;
}
Template Examples
| Template ID | Channel | Subject | Body |
|---|---|---|---|
| order_confirmed | Order #{{orderId}} Confirmed | Hi {{name}}, your order from {{restaurant}} is confirmed! Total: ${{total}} | |
| order_confirmed | PUSH | Order Confirmed | Your order from {{restaurant}} is being prepared |
| order_confirmed | SMS | - | Your order #{{orderId}} from {{restaurant}} is confirmed. Total: ${{total}} |
| otp_verify | SMS | - | Your OTP is {{otp}}. Valid for 5 minutes. |
| password_reset | Reset Your Password | Click here to reset: {{resetLink}} | |
| promo_marketing | {{promoTitle}} | {{promoBody}} |
Delivery Tracking
@Service
public class DeliveryTracker {
private final DeliveryLogRepository logRepo;
private final KafkaTemplate<String, DeliveryEvent> kafka;
public void track(Notification notification, DeliveryResult result) {
DeliveryLog log = new DeliveryLog();
log.setNotificationId(notification.getId());
log.setChannel(notification.getChannel());
log.setStatus(result.isSuccess() ? DeliveryStatus.SENT : DeliveryStatus.FAILED);
log.setSentAt(result.isSuccess() ? LocalDateTime.now() : null);
log.setFailureReason(result.getReason());
logRepo.save(log);
kafka.send("delivery-events", notification.getId(),
new DeliveryEvent(notification.getId(), log.getStatus()));
}
public void markDelivered(String notificationId) {
DeliveryLog log = logRepo.findByNotificationId(notificationId);
log.setStatus(DeliveryStatus.DELIVERED);
log.setDeliveredAt(LocalDateTime.now());
logRepo.save(log);
}
public void markOpened(String notificationId) {
DeliveryLog log = logRepo.findByNotificationId(notificationId);
log.setStatus(DeliveryStatus.OPENED);
log.setOpenedAt(LocalDateTime.now());
logRepo.save(log);
}
public DeliveryStats getStats(String userId, LocalDateTime from, LocalDateTime to) {
List<DeliveryLog> logs = logRepo.findByUserIdAndDateRange(userId, from, to);
return DeliveryStats.builder()
.totalSent((int) logs.stream().filter(l -> l.getStatus() != DeliveryStatus.FAILED).count())
.totalDelivered((int) logs.stream().filter(l -> l.getStatus() == DeliveryStatus.DELIVERED).count())
.totalOpened((int) logs.stream().filter(l -> l.getStatus() == DeliveryStatus.OPENED).count())
.totalFailed((int) logs.stream().filter(l -> l.getStatus() == DeliveryStatus.FAILED).count())
.build();
}
}
Follow-ups & Advanced Topics
Observer Pattern — Delivery Status Events
public interface DeliveryEventListener {
void onDeliveryEvent(DeliveryEvent event);
}
public class AnalyticsListener implements DeliveryEventListener {
@Override
public void onDeliveryEvent(DeliveryEvent event) {
// Track in analytics pipeline
analyticsService.track("notification.delivery", Map.of(
"notificationId", event.getNotificationId(),
"status", event.getStatus().name(),
"timestamp", Instant.now().toString()
));
}
}
public class AlertListener implements DeliveryEventListener {
@Override
public void onDeliveryEvent(DeliveryEvent event) {
if (event.getStatus() == DeliveryStatus.FAILED) {
alertService.incrementFailureCount();
if (alertService.getFailureCount(5) > 1000) {
alertService.sendCriticalAlert("High notification failure rate!");
}
}
}
}
public class WebhookListener implements DeliveryEventListener {
@Override
public void onDeliveryEvent(DeliveryEvent event) {
if (event.hasWebhook()) {
webhookClient.post(event.getWebhookUrl(), event);
}
}
}
Database Schema
CREATE TABLE notifications (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
type VARCHAR(20) NOT NULL,
channel VARCHAR(20) NOT NULL,
priority VARCHAR(10) DEFAULT 'MEDIUM',
template_id VARCHAR(36),
variables JSON,
rendered_subject TEXT,
rendered_body TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'CREATED',
retry_count INT DEFAULT 0,
metadata JSON,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
INDEX idx_user_status (user_id, status),
INDEX idx_created (created_at)
);
CREATE TABLE templates (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
channel VARCHAR(20) NOT NULL,
subject VARCHAR(500),
body TEXT NOT NULL,
language VARCHAR(10) DEFAULT 'en',
required_variables JSON,
version INT DEFAULT 1,
is_active BOOLEAN DEFAULT TRUE,
INDEX idx_name_lang (name, language)
);
CREATE TABLE user_preferences (
user_id VARCHAR(36) NOT NULL,
channel VARCHAR(20) NOT NULL,
opted_in BOOLEAN DEFAULT TRUE,
quiet_hours_start TIME,
quiet_hours_end TIME,
max_per_hour INT,
max_per_day INT,
PRIMARY KEY (user_id, channel)
);
CREATE TABLE delivery_logs (
id VARCHAR(36) PRIMARY KEY,
notification_id VARCHAR(36) NOT NULL,
channel VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
sent_at TIMESTAMP,
delivered_at TIMESTAMP,
opened_at TIMESTAMP,
clicked_at TIMESTAMP,
failure_reason TEXT,
FOREIGN KEY (notification_id) REFERENCES notifications(id),
INDEX idx_notification (notification_id),
INDEX idx_status (status)
);
CREATE TABLE rate_limits (
user_id VARCHAR(36) NOT NULL,
channel VARCHAR(20) NOT NULL,
window_start TIMESTAMP NOT NULL,
count INT DEFAULT 0,
PRIMARY KEY (user_id, channel, window_start)
);
Common Interview Follow-Up Questions
**Q: How do you handle notification ordering?
- Kafka topic partitioned by userId — guarantees FIFO per user
- Per-channel ordering: separate topics per channel (email-topic, push-topic)
- For critical notifications (OTP), bypass queue and send synchronously
**Q: How do you prevent duplicate notifications?
- Idempotency key: (userId, templateId, timestamp_minute) → dedup in Redis
- At-least-once delivery: check delivery_log before sending
- Client-side: in-app notifications use notificationId for dedup
**Q: How do you handle channel fallback?
- If push fails → fallback to SMS (for critical notifications only)
- If SMS fails → fallback to email
- Configurable per notification type:
fallbackChannels: [SMS, EMAIL] - Example: OTP → try push first, fallback to SMS after 5s
**Q: How do you handle internationalization (i18n)?
- Template has language field, query by (templateId, userLanguage)
- Fallback chain: user preferred → default locale → English
- Variables can contain locale-specific formatting (dates, currency)
- Store translations in DB, cache rendered templates
**Q: How do you handle high-priority notifications (e.g., OTP)?
- Priority queue: separate Kafka topic for HIGH priority
- Bypass frequency caps and quiet hours for critical notifications
- Synchronous send path for OTP (< 100ms latency requirement)
- Retry immediately with shorter backoff (1s, 2s, 4s)
**Q: How do you handle bulk/marketing notifications?
- Async processing via Kafka consumer groups
- Throttled sending: max 10K/sec to avoid provider limits
- Batch API calls to email/SMS providers
- Track per-user engagement for A/B testing
**Q: How do you handle notification analytics?
- Event stream: delivery-events Kafka topic → analytics pipeline
- Metrics: delivery rate, open rate, click rate, failure rate
- Per-template and per-channel breakdowns
- Alert on anomalies: sudden spike in failures, drop in delivery rate
Practice Problems
Design a scalable Notification System (LLD) system. Cover high-level architecture, data model, and API design.
Solution
// Complete system design:
// - Functional + Non-functional requirements
// - Capacity estimation
// - Data model (SQL/NoSQL choice)
// - API endpoints
// - Component architecture
// - Scaling strategy
// - Monitoring & reliabilityHow would you scale Notification System (LLD) to handle 10x the current load? Identify bottlenecks and solutions.
Solution
// Scaling approach:
// 1. Load balancing
// 2. Database sharding/replication
// 3. Cache layer (Redis)
// 4. CDN for static assets
// 5. Async processing (queues)
// 6. Microservices decompositionAnalyze potential failure modes for Notification System (LLD) and design mitigation strategies.
Solution
// Failure mitigation:
// 1. Redundancy (multi-AZ)
// 2. Circuit breakers
// 3. Retry with backoff
// 4. Dead letter queues
// 5. Health checks
// 6. Graceful degradationQuiz
1. What is the correct order of the notification delivery pipeline?
2. Why use a dead letter queue (DLQ) instead of failing silently?
3. How should quiet hours be handled for critical notifications (e.g., OTP)?
4. What pattern is used to make the notification pipeline extensible?
5. How do you handle sending to a user who has no device token for push notifications?
Flashcards
Question
What are the 6 steps in the notification delivery pipeline?
Click to reveal answer
Answer
Create → Validate → Enrich → Route → Send → Track
Question
Which design pattern makes the pipeline steps customizable?
Click to reveal answer
Answer
Template Method Pattern — defines the skeleton (validate → enrich → route → send) while allowing each step to be overridden
Question
How do you prevent sending notifications during user quiet hours?
Click to reveal answer
Answer
PreferenceManager.isInQuietHours() checks the user's configured quiet window. Bypass only for CRITICAL priority (OTP, security).
Question
What is the purpose of a Dead Letter Queue?
Click to reveal answer
Answer
Stores permanently failed notifications for investigation and replay. Provides visibility into failures without blocking the main pipeline.
Question
How does rate limiting work per user per channel?
Click to reveal answer
Answer
Redis sliding window counter: key = userId:channel, increment on send, check against cap (e.g., 10 pushes/hour, 3 SMS/hour).
Question
Which pattern allows swapping channel implementations (push/SMS/email)?
Click to reveal answer
Answer
Strategy Pattern — NotificationChannel interface with PushNotificationChannel, SmsNotificationChannel, EmailNotificationChannel implementations.
Question
How do you handle retry for failed notifications?
Click to reveal answer
Answer
Exponential backoff: 1s, 2s, 4s, 8s, 16s (capped at 60s) with jitter. After MAX_RETRIES (5), move to DLQ.
Question
How do you support multiple languages in notification templates?
Click to reveal answer
Answer
Templates have a language field. Query by (templateId, userLanguage), fallback to default locale → English. Cache rendered templates in Redis.
Revision Notes
Key Takeaways
- 1.6-step pipeline: Create → Validate → Enrich → Route → Send → Track
- 2.Strategy Pattern for channel selection (push/SMS/email/in-app)
- 3.Template Method Pattern for the delivery pipeline skeleton
- 4.User preferences: opt-in/out, quiet hours, frequency caps — always check before sending
- 5.Rate limiting: Redis sliding window per (userId, channel) with Lua scripts
- 6.Retry: exponential backoff (1s × 2^n + jitter), DLQ after 5 failures
- 7.Template engine: variable substitution {{var}}, i18n with language fallback
- 8.Delivery tracking: sent → delivered → opened → clicked (for analytics)
Interview Tips
- •Start with notification types: transactional, marketing, system, social
- •Draw the 6-step pipeline first — it's the core of the design
- •Use Strategy Pattern for channels — interviewers expect this
- •Discuss user preferences early: quiet hours, frequency caps, opt-in/out
- •Mention rate limiting per user per channel — prevents spam
- •Handle retries: exponential backoff + DLQ for failed messages
- •Template system: variable substitution + i18n is a great follow-up
- •Be ready for: channel fallback, deduplication, bulk sending, analytics
- •Know the trade-offs: push (fast, cheap) vs SMS (reliable, expensive) vs email (rich, slow)
- •Mention idempotency to prevent duplicate notifications
Cheat Sheet
Notification System LLD Cheat Sheet
Delivery Pipeline
CREATE → VALIDATE → ENRICH → ROUTE → SEND → TRACK
Channels
| Channel | Provider | Use Case | Latency |
|---|---|---|---|
| Push | APNs/FCM | Real-time alerts | < 1s |
| SMS | Twilio | OTP, critical alerts | < 5s |
| SES | Marketing, detailed info | < 30s | |
| In-App | WebSocket | Non-urgent, rich content | < 1s |
Design Patterns Used
| Pattern | Where | Why |
|---|---|---|
| Strategy | Channel selection | Swap push/SMS/email implementations |
| Template Method | Delivery pipeline | Common flow with customizable steps |
| Observer | Delivery status events | Decouple tracking from analytics/alerts |
| Factory | Notification creation | Create channel-specific notification objects |
User Preferences
- Opt-in/out: per-channel boolean (push, SMS, email, in-app)
- Quiet hours: time window (e.g., 10PM-8AM), bypass for CRITICAL only
- Frequency caps: max per hour per channel (e.g., 10 pushes/hr, 3 SMS/hr)
- Topic subscriptions: subscribe/unsubscribe to specific notification categories
Rate Limiting
- Redis sliding window counter per (userId, channel)
- Lua script for atomic check-and-increment
- Separate caps per channel (SMS stricter than push)
Retry & Reliability
- Exponential backoff: 1s × 2^n + jitter, capped at 60s
- Max 5 retries before DLQ
- DLQ: stores failed notifications for investigation/replay
- Alert on DLQ threshold (> 100 failures in 5 min)
Template System
- Templates stored in DB with (id, channel, language, version)
- Variable substitution: {{variable}} syntax
- i18n: query by (templateId, userLanguage), fallback to default
- Cache rendered templates in Redis for performance
Scalability
- Notification creation: Stateless API, partition by userId
- Delivery: Kafka consumer groups per channel
- Template rendering: Redis cache, DB fallback
- Rate limiting: Redis cluster, partition by userId
Common Pitfalls
- Don't block on provider API calls — use async with timeouts
- Handle provider outages gracefully — fallback to alternative channel
- Deduplicate: idempotency key = (userId, templateId, timestamp_minute)
- Don't send marketing during quiet hours — always respect user prefs
- Monitor delivery rates — alert on sudden drops
- Use DLQ, don't fail silently — visibility into failures is critical