LLD Hub
lldobserverstrategytemplate-method

Notification System Low Level Design — LLD Interview Guide

Design a multi-channel notification service (Email, SMS, Push, In-App) with rate limiting, retry logic, and user preferences. Common at Razorpay, PhonePe.

10 April 2025·8 min read

Practice this problem

Notification System — get AI-scored feedback on your solution

Solve it →

Notification System Low Level Design is a core infrastructure problem asked at Razorpay, PhonePe, Swiggy, and Meesho. It requires multi-channel delivery (Email, SMS, Push, In-App), user preference management, rate limiting, and retry with backoff. This guide covers the complete Notification System LLD with Java code, class diagram, and interview FAQ.

Why Interviewers Ask Notification System LLD

Almost every product requires notifications. Interviewers use this problem to test:

  • Do you use Template Method or Strategy for per-channel formatting?
  • Can you design user preference management — opt-in/opt-out per channel and category?
  • Do you implement retry with exponential backoff for failed deliveries?
  • Can you rate-limit per user per channel (e.g., max 5 SMS/day)?
  • Do you separate notification creation from delivery (event-driven architecture)?

Functional Requirements

  • Send notifications via Email, SMS, Push (FCM/APNs), and In-App channels
  • Users can set preferences — opt out of specific channels or notification categories
  • Support notification categories: TRANSACTION, MARKETING, ALERT, REMINDER
  • Failed deliveries must be retried with exponential backoff (max 3 attempts)
  • Rate limit notifications per user: max 5 SMS per day, 50 push per day
  • Track delivery status: PENDING, SENT, DELIVERED, FAILED
  • Support templated messages — Hello {name}, your order {orderId} is ready

Non-Functional Requirements

  • Notification send must not block the calling service (fire-and-forget)
  • System must handle 1 million notifications per hour
  • Adding a new channel (e.g., WhatsApp) must not change existing channels
  • Template rendering must be injected — no hardcoded strings in service code

Core Entities — Notification System LLD Class Design

  • Notification — id, userId, category, templateId, params, channels, status
  • NotificationChannel — interface; EmailChannel, SMSChannel, PushChannel, InAppChannel
  • UserPreference — userId, channel, category, isEnabled
  • NotificationTemplate — id, channel, category, subjectTemplate, bodyTemplate
  • DeliveryRecord — notificationId, channel, attempt, status, timestamp, error
  • NotificationService — entry point; applies preferences, routes to channels
  • RetryScheduler — exponential backoff retry for FAILED deliveries
  • RateLimiter — per-user per-channel rate limiting

Text-Based Class Diagram

Notification
+-- id, userId: String
+-- category: NotificationCategory
+-- templateId: String
+-- params: Map<String, String>
+-- channels: List<ChannelType>
+-- status: NotificationStatus (PENDING/PARTIAL/SENT/FAILED)

NotificationChannel (interface)
+-- send(notification, renderedContent): DeliveryResult
+-- getChannelType(): ChannelType

EmailChannel    implements NotificationChannel
SMSChannel      implements NotificationChannel
PushChannel     implements NotificationChannel
InAppChannel    implements NotificationChannel

UserPreference
+-- userId, channel: ChannelType
+-- category: NotificationCategory
+-- isEnabled: boolean

NotificationTemplate
+-- id, channelType, category: String
+-- subjectTemplate, bodyTemplate: String

DeliveryRecord
+-- notificationId: String
+-- channel: ChannelType
+-- attempt: int
+-- status: DeliveryStatus
+-- sentAt, failedAt: LocalDateTime
+-- errorMessage: String

NotificationChannel — Strategy Pattern

public interface NotificationChannel {
    DeliveryResult send(String userId, RenderedContent content);
    ChannelType getChannelType();
}

public class EmailChannel implements NotificationChannel {
    private final EmailGateway emailGateway;

    @Override
    public DeliveryResult send(String userId, RenderedContent content) {
        String email = userRepo.getEmail(userId);
        try {
            emailGateway.send(email, content.getSubject(), content.getBody());
            return DeliveryResult.success(ChannelType.EMAIL);
        } catch (EmailException e) {
            return DeliveryResult.failure(ChannelType.EMAIL, e.getMessage());
        }
    }

    @Override
    public ChannelType getChannelType() { return ChannelType.EMAIL; }
}

public class SMSChannel implements NotificationChannel {
    private final SmsGateway smsGateway;

    @Override
    public DeliveryResult send(String userId, RenderedContent content) {
        String phone = userRepo.getPhone(userId);
        try {
            smsGateway.send(phone, content.getBody()); // SMS has no subject
            return DeliveryResult.success(ChannelType.SMS);
        } catch (SmsException e) {
            return DeliveryResult.failure(ChannelType.SMS, e.getMessage());
        }
    }

    @Override
    public ChannelType getChannelType() { return ChannelType.SMS; }
}

NotificationService — Preference Filtering and Routing

public class NotificationService {
    private final Map<ChannelType, NotificationChannel> channels;
    private final PreferenceService preferenceService;
    private final TemplateEngine templateEngine;
    private final RateLimiter rateLimiter;
    private final DeliveryRecordRepository deliveryRepo;

    public void send(Notification notification) {
        for (ChannelType channelType : notification.getChannels()) {
            // Check user preference
            if (!preferenceService.isEnabled(notification.getUserId(), channelType, notification.getCategory())) {
                continue;
            }
            // Check rate limit
            if (!rateLimiter.tryAcquire(notification.getUserId(), channelType)) {
                log("Rate limit exceeded for user " + notification.getUserId() + " on " + channelType);
                continue;
            }

            NotificationTemplate template = templateEngine.getTemplate(channelType, notification.getCategory());
            RenderedContent content = templateEngine.render(template, notification.getParams());

            NotificationChannel channel = channels.get(channelType);
            DeliveryResult result = channel.send(notification.getUserId(), content);

            DeliveryRecord record = new DeliveryRecord(notification.getId(), channelType,
                1, result.getStatus(), LocalDateTime.now(), result.getError());
            deliveryRepo.save(record);

            if (result.getStatus() == DeliveryStatus.FAILED) {
                retryScheduler.schedule(notification, channelType, 1);
            }
        }
    }
}

Retry with Exponential Backoff

public class RetryScheduler {
    private static final int MAX_ATTEMPTS = 3;
    private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(4);
    private final NotificationService notificationService;

    public void schedule(Notification notification, ChannelType channel, int attempt) {
        if (attempt >= MAX_ATTEMPTS) {
            markPermanentlyFailed(notification, channel);
            return;
        }
        long delaySeconds = (long) Math.pow(2, attempt) * 30; // 30s, 60s, 120s
        scheduler.schedule(() -> retry(notification, channel, attempt + 1),
            delaySeconds, TimeUnit.SECONDS);
    }

    private void retry(Notification notification, ChannelType channel, int attempt) {
        NotificationChannel ch = channelMap.get(channel);
        RenderedContent content = templateEngine.render(notification);
        DeliveryResult result = ch.send(notification.getUserId(), content);

        DeliveryRecord record = new DeliveryRecord(notification.getId(), channel,
            attempt, result.getStatus(), LocalDateTime.now(), result.getError());
        deliveryRepo.save(record);

        if (result.getStatus() == DeliveryStatus.FAILED) {
            schedule(notification, channel, attempt);
        }
    }
}

Key Design Decisions

  • Strategy per channel instead of if-else: Each channel is a separate class. Adding WhatsApp is a new WhatsAppChannel class with no changes to NotificationService. The channel map is populated at startup via dependency injection.
  • Preference checked before rate limiting: If a user opted out, skip rate limit consumption. Rate limit is a shared resource — don not waste tokens on messages the user will never receive.
  • ScheduledExecutorService for retry: Retries are scheduled non-blocking. The main send() returns immediately. Retries happen in the background with exponential backoff (30s, 60s, 120s). In production, use a durable job queue (SQS, Kafka) instead of in-process.
  • Template engine separation: TemplateEngine is injected into NotificationService. Message templates live in a database or config store, not in Java strings. This lets marketing teams update copy without code deployments.

Common Follow-Up Questions

  • "How do you handle notification batching?" — Collect notifications for the same user within a 5-minute window and send as a single digest email. Use a scheduled job that queries PENDING notifications grouped by userId and bundles them.
  • "How do you prioritize ALERT notifications over MARKETING?" — Use two queues: HIGH_PRIORITY (ALERT, TRANSACTION) and LOW_PRIORITY (MARKETING, REMINDER). Workers on the high priority queue run at 4x the thread count of low priority.
  • "How do you track delivery status to the user's device?" — Push channels get delivery receipts via FCM/APNs callbacks. Register a webhook that updates DeliveryRecord status from SENT to DELIVERED when the device ACKs.

FAQ — Notification System Low Level Design

What design patterns are used in Notification System LLD?

The primary patterns are Strategy (NotificationChannel per delivery channel),Template Method (base notification flow with per-channel overrides), andObserver (channels subscribe to delivery status callbacks). TheChain of Responsibility can optionally model the preference → rate-limit → send pipeline.

How do you implement user notification preferences?

Store a UserPreference record per (userId, channelType, category) combination with an isEnabled flag. Before routing to a channel, query preferences. For performance, cache preferences in Redis with a short TTL (5 minutes) — preference changes propagate within one cache cycle.

How do you rate-limit notifications per user?

Use a Token Bucket per (userId, channelType) pair. SMS: 5 tokens/day, Push: 50 tokens/day. Store bucket state in Redis with a daily expiry key. On each send attempt, call tryAcquire — if it returns false, skip the channel without consuming a retry attempt.

How do you handle notification templates?

Store templates in a database: templateId, channelType, category, subjectTemplate, bodyTemplate. Use a simple variable replacement engine (Mustache or custom) to inject params like name and orderId at send time. This keeps templates editable without code changes.

Ready to practice?

Submit your solution and get AI-scored feedback on OOP, SOLID principles, design patterns, and code quality.

Solve Notification System