A Notification System that handles multiple channels (Email, SMS, Push, In-App) with rate limiting, retry, and user preferences is a frequent LLD problem at Razorpay, PhonePe, Freshworks, and other product companies. Let's design it end-to-end.
Core Entities
- Notification — type, title, body, priority, recipient
- Channel — Email, SMS, Push, InApp
- UserPreference — which channels enabled per notification type
- NotificationService — routes notifications to appropriate channels
- RateLimiter — prevents channel flooding
- RetryQueue — retries failed deliveries
Strategy Pattern for Channels
interface NotificationChannel {
send(notification: Notification, user: User): boolean;
supports(type: NotificationType): boolean;
}
class EmailChannel implements NotificationChannel {
send(n: Notification, user: User): boolean {
if (!user.email) return false;
return this.emailClient.send(user.email, n.title, n.body);
}
}
class SMSChannel implements NotificationChannel { ... }
class PushChannel implements NotificationChannel { ... }
class InAppChannel implements NotificationChannel { ... }Routing by User Preference
class NotificationService {
send(notification: Notification, userId: string): void {
const prefs = this.prefRepo.get(userId, notification.type);
const channels = this.channels.filter(c =>
prefs.enabledChannels.includes(c.name) && c.supports(notification.type)
);
for (const channel of channels) {
if (!this.rateLimiter.allow(userId, channel.name)) {
if (notification.priority === Priority.CRITICAL) {
this.bypassAndSend(channel, notification, userId); // Critical bypasses limits
}
continue;
}
const success = channel.send(notification, this.userRepo.get(userId));
if (!success) this.retryQueue.enqueue(notification, channel, userId);
}
}
}Rate Limiter
class RateLimiter {
// Sliding window per user per channel
allow(userId: string, channel: string): boolean {
const key = `${userId}:${channel}`;
const count = this.countInLastHour(key);
const limit = this.getLimitForChannel(channel); // Email: 100/hr, SMS: 10/hr
return count < limit;
}
}Retry with Exponential Backoff
class RetryQueue {
enqueue(notification: Notification, channel: NotificationChannel, userId: string) {
const job = { notification, channel, userId, attempts: 0 };
this.schedule(job, 1000); // First retry in 1 second
}
private retry(job: RetryJob) {
if (job.attempts >= 3) { this.dlq.add(job); return; }
const success = job.channel.send(job.notification, this.userRepo.get(job.userId));
if (!success) {
job.attempts++;
this.schedule(job, Math.pow(2, job.attempts) * 1000); // Exponential backoff
}
}
}