Skip to content
1 min read

Design a Notification Service

Interview Context

Asked at: Meta, Amazon, Uber, Netflix | Level: L4-L6 | Time: 45 minutes | Type: LLD/OOP Design


Requirements

Functional

  • Send notifications via multiple channels: Email, SMS, Push
  • Users configure channel preferences (opt-in/opt-out per type)
  • Template-based messages with variable substitution
  • Priority levels: critical (immediate), high, medium, low (batched)
  • Rate limiting per user per channel (e.g., max 3 SMS/hour)
  • Notification history and read/unread status

Non-Functional

  • At-least-once delivery guarantee
  • Rate limiting prevents user fatigue
  • Channel failures should not block other channels
  • Extensible to new channels (Slack, WhatsApp) without core changes

Class Diagram

%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
classDiagram
    class NotificationService {
        -List~Channel~ channels
        -UserPreferenceStore preferences
        -RateLimiter rateLimiter
        +send(Notification notification) void
    }

    class Notification {
        -String id
        -String userId
        -NotificationType type
        -Priority priority
        -Template template
        -Map~String, String~ variables
        -LocalDateTime createdAt
    }

    class Channel {
        <<interface>>
        +send(String userId, String message) boolean
        +getType() ChannelType
    }

    class EmailChannel {
        +send(String userId, String message) boolean
        +getType() ChannelType
    }

    class SmsChannel {
        +send(String userId, String message) boolean
        +getType() ChannelType
    }

    class PushChannel {
        +send(String userId, String message) boolean
        +getType() ChannelType
    }

    class Template {
        -String templateId
        -String body
        +render(Map~String, String~ vars) String
    }

    class UserPreference {
        -String userId
        -Map~NotificationType, Set~ChannelType~~ preferences
        +getChannels(NotificationType type) Set~ChannelType~
    }

    class RateLimiter {
        -Map~String, TokenBucket~ buckets
        +allowRequest(String userId, ChannelType channel) boolean
    }

    class Priority {
        <<enumeration>>
        CRITICAL
        HIGH
        MEDIUM
        LOW
    }

    NotificationService --> Channel
    NotificationService --> RateLimiter
    NotificationService --> UserPreference
    Notification --> Template
    Notification --> Priority
    EmailChannel ..|> Channel
    SmsChannel ..|> Channel
    PushChannel ..|> Channel

Key Design Decisions

Decision Choice Why
Multi-channel dispatch Strategy Pattern Each channel has its own send logic
Template rendering Template Method Common render logic, channel-specific formatting
Rate limiting Token Bucket per user-channel Simple, allows bursts, configurable rate
User preferences Preference store lookup Respects opt-outs before sending
Notification building Builder Pattern Many optional fields (priority, template, vars)

Java Implementation

Java
public enum ChannelType { EMAIL, SMS, PUSH }
public enum Priority { CRITICAL, HIGH, MEDIUM, LOW }
public enum NotificationType { ORDER_UPDATE, PROMOTION, SECURITY_ALERT, SOCIAL }

public class Notification {
    private final String id;
    private final String userId;
    private final NotificationType type;
    private final Priority priority;
    private final Template template;
    private final Map<String, String> variables;

    private Notification(Builder builder) {
        this.id = UUID.randomUUID().toString();
        this.userId = builder.userId;
        this.type = builder.type;
        this.priority = builder.priority;
        this.template = builder.template;
        this.variables = Map.copyOf(builder.variables);
    }

    public String renderMessage() { return template.render(variables); }

    public static class Builder {
        private String userId;
        private NotificationType type;
        private Priority priority = Priority.MEDIUM;
        private Template template;
        private Map<String, String> variables = new HashMap<>();

        public Builder userId(String id) { this.userId = id; return this; }
        public Builder type(NotificationType t) { this.type = t; return this; }
        public Builder priority(Priority p) { this.priority = p; return this; }
        public Builder template(Template t) { this.template = t; return this; }
        public Builder variable(String key, String val) { variables.put(key, val); return this; }
        public Notification build() { return new Notification(this); }
    }
}

public class Template {
    private final String templateId;
    private final String body; // "Hello {{name}}, your order {{orderId}} is {{status}}"

    public String render(Map<String, String> variables) {
        String result = body;
        for (var entry : variables.entrySet()) {
            result = result.replace("{{" + entry.getKey() + "}}", entry.getValue());
        }
        return result;
    }
}
Java
public interface Channel {
    boolean send(String userId, String renderedMessage);
    ChannelType getType();
}

public class EmailChannel implements Channel {
    private final EmailClient emailClient;

    @Override
    public boolean send(String userId, String message) {
        String email = lookupEmail(userId);
        return emailClient.send(email, "Notification", message);
    }

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

public class NotificationService {
    private final Map<ChannelType, Channel> channels;
    private final UserPreferenceStore preferenceStore;
    private final RateLimiter rateLimiter;

    public void send(Notification notification) {
        String userId = notification.getUserId();
        Set<ChannelType> userChannels = preferenceStore
            .getPreference(userId)
            .getChannels(notification.getType());

        String message = notification.renderMessage();

        for (ChannelType channelType : userChannels) {
            // Skip if rate limited (unless CRITICAL)
            if (notification.getPriority() != Priority.CRITICAL
                    && !rateLimiter.allowRequest(userId, channelType)) {
                continue;
            }

            Channel channel = channels.get(channelType);
            if (channel != null) {
                // Fire-and-forget per channel — one failure doesn't block others
                CompletableFuture.runAsync(() -> channel.send(userId, message));
            }
        }
    }
}
Java
public class RateLimiter {
    private final Map<String, TokenBucket> buckets = new ConcurrentHashMap<>();
    private final int maxTokens;
    private final Duration refillInterval;

    public RateLimiter(int maxTokens, Duration refillInterval) {
        this.maxTokens = maxTokens;
        this.refillInterval = refillInterval;
    }

    public boolean allowRequest(String userId, ChannelType channel) {
        String key = userId + ":" + channel.name();
        TokenBucket bucket = buckets.computeIfAbsent(key,
            k -> new TokenBucket(maxTokens, refillInterval));
        return bucket.tryConsume();
    }
}

public class TokenBucket {
    private final int maxTokens;
    private final Duration refillInterval;
    private int tokens;
    private Instant lastRefill;

    public TokenBucket(int maxTokens, Duration refillInterval) {
        this.maxTokens = maxTokens;
        this.refillInterval = refillInterval;
        this.tokens = maxTokens;
        this.lastRefill = Instant.now();
    }

    public synchronized boolean tryConsume() {
        refill();
        if (tokens > 0) { tokens--; return true; }
        return false;
    }

    private void refill() {
        Instant now = Instant.now();
        long elapsed = Duration.between(lastRefill, now).toMillis();
        long intervalsElapsed = elapsed / refillInterval.toMillis();
        if (intervalsElapsed > 0) {
            tokens = (int) Math.min(maxTokens, tokens + intervalsElapsed);
            lastRefill = now;
        }
    }
}

SOLID Principles Applied

Principle How Applied
S — Single Responsibility Channel sends messages; RateLimiter controls rate; Template renders text
O — Open/Closed New channels (Slack, WhatsApp) added by implementing Channel interface
L — Liskov Substitution Any Channel impl works in the dispatch loop
I — Interface Segregation Channel has one method; rate limiter is separate from delivery
D — Dependency Inversion NotificationService depends on Channel interface, not concrete Email/SMS classes

Interview Walkthrough (45 minutes)

Time What to Do
0-5 min Clarify: channels supported, priority levels, rate limits, delivery guarantees
5-15 min Draw class diagram — Notification (Builder), Channel, RateLimiter, UserPreference
15-25 min Explain: channel dispatch, rate limiting (token bucket), preference filtering
25-35 min Code: Notification.Builder, NotificationService.send(), TokenBucket
35-45 min Discuss: retry with backoff, dead letter queue, batch delivery for LOW priority