7. Notification System Intermediate → Advanced

A. Realistic Interview Prompt

"Design a notification system that can send a message to a user through multiple channels — email, SMS, push. It should respect user preferences, avoid spamming any one user, and retry failed deliveries."

B. Difficulty

Intermediate for the core multi-channel dispatch; Advanced once rate limiting, retry-with-backoff, and idempotent at-least-once delivery enter — at which point this problem directly reuses machinery from Problem 3 (Rate Limiter) and shares its concurrency lessons with Problem 4 (Splitwise) and Problem 5 (Ride-Sharing).

C. What This Problem Tests

D. Requirements

Functional

  1. Send a notification to a user through one or more channels (email, SMS, push, in-app)
  2. Respect per-user, per-channel, per-category opt-in/opt-out preferences
  3. Render notification content from a template plus parameters
  4. Rate-limit notifications per user per channel (e.g. max 5 SMS/day)
  5. Retry failed deliveries with backoff, up to a bounded number of attempts

Non-Functional

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1Which channels are required — email, SMS, push, in-app, all of them?Determines the NotificationChannel implementation setChannel interface scope
2Are notifications triggered synchronously by calling code, or by internal system events (e.g. "order shipped")?Determines whether an Observer/event layer sits in front of the serviceTrigger-side architecture
3Is rate limiting per channel, per category, or both?Changes the key shape of the rate limiterRateLimiter key: (userId, channel) vs. (userId, channel, category)
4What's the retry policy — fixed delay, exponential backoff, max attempts?Drives the RetryScheduler designBackoff parameters
5Can a user opt out at the category level (e.g. "no promotions") separately from the channel level?Determines the UserPreference key granularityUserPreference shape
6Is delivery at-least-once (may duplicate, needs idempotency) or best-effort (may silently drop)?The single biggest correctness decision in this problemIdempotency-key requirement on Notification
7Should failed notifications after max retries be surfaced anywhere (a dead-letter queue, an alert)?Affects whether failures are silent or observableDeadLetterQueue presence

F. Assumptions

G. Actors

ActorResponsibility
Triggering system/eventFires "something happened" (order shipped, password reset, promo)
UserSets channel/category preferences, receives notifications
NotificationServiceChecks preferences, checks rate limits, renders, dispatches, retries

H. Core Domain Entities

ClassResponsibilityKey AttributesKey MethodsDoes NOT Handle
NotificationOne dispatch request's full recordid, userId, category, templateId, params, channels, idempotencyKey, status—Deciding if/how it's sent (delegates)
NotificationTemplateContent pattern per category+channelsubject/body pattern with placeholdersrender(params)Delivery
UserPreferencePer-user opt-in/opt-outuserId, channel, category, enabledisAllowed(userId, channel, category)Rate limiting, sending
NotificationChannel (interface)Delivers a rendered notification via one medium—send(renderedContent, recipient)Preference or rate-limit checks (already done before this is called)
RateLimiter (reused from Problem 3)Caps sends per (userId, channel)—tryAcquire(key)Everything except the yes/no throttling decision
RetrySchedulerRe-attempts failed sends with backoffattempt count, next-retry timescheduleRetry(notification, attempt)Deciding why it failed
DeadLetterQueueHolds notifications that exhausted retries—record(notification, lastError)Automatic recovery (manual/operator concern)
NotificationService (facade)Orchestrates the whole pipeline—notify(userId, category, templateId, params, channels)Rendering, rate math, channel I/O — all delegated

I. Class Relationships

J. Class Diagram

Notification

userId

category

templateId

params

idempotencyKey

status

NotificationTemplate

+render(params)

UserPreference

+isAllowed(userId, channel, category)

«interface»

NotificationChannel

+send(content, recipient)

RateLimiter

+tryAcquire(key)

RetryScheduler

+scheduleRetry(notification, attempt)

DeadLetterQueue

+record(notification, error)

NotificationService

+notify(userId, category, templateId, params, channels)

EmailChannel

SMSChannel

PushChannel

View Mermaid source
classDiagram
  class Notification {
    userId
    category
    templateId
    params
    idempotencyKey
    status
  }
  class NotificationTemplate {
    +render(params)
  }
  class UserPreference {
    +isAllowed(userId, channel, category)
  }
  class NotificationChannel {
    <<interface>>
    +send(content, recipient)
  }
  class RateLimiter {
    +tryAcquire(key)
  }
  class RetryScheduler {
    +scheduleRetry(notification, attempt)
  }
  class DeadLetterQueue {
    +record(notification, error)
  }
  class NotificationService {
    +notify(userId, category, templateId, params, channels)
  }
  NotificationService --> UserPreference
  NotificationService --> RateLimiter
  NotificationService --> NotificationTemplate
  NotificationService --> NotificationChannel
  NotificationService --> RetryScheduler
  NotificationChannel <|.. EmailChannel
  NotificationChannel <|.. SMSChannel
  NotificationChannel <|.. PushChannel
  RetryScheduler --> DeadLetterQueue

K. Responsibility Mapping

ClassResponsibilityDoes NOT Handle
UserPreferenceYes/no on whether this user wants thisWhether it's rate-limited or how it's sent
RateLimiterYes/no on whether this send is allowed right nowPreferences, content, delivery
NotificationChannelThe mechanics of one delivery mediumWhether this send should happen at all
RetrySchedulerWhat happens after a failureWhy it failed, or the original dispatch decision
NotificationServiceOrchestration — runs the pipeline in the correct orderEvery individual check's actual logic (delegated)

L. SOLID Analysis

PrincipleHow It's AppliedNaive Violation
SRPPreference checking, rate limiting, rendering, sending, and retrying are five distinct classesOne NotificationService method with all five concerns inlined and no seams for testing any of them independently
OCPNew channel = new NotificationChannel; zero changes to NotificationServiceif (channel == "SMS") ... else if (channel == "EMAIL") ... inline
LSPEvery channel's send() returns a consistent success/failure result the retry logic can act on uniformlyOne channel throwing exceptions on failure while another returns a null silently — retry logic can't treat them the same way
ISPOne-method NotificationChannel interfaceA fat interface also requiring validateAddress(), trackOpens(), unsubscribeLink() on every implementer regardless of relevance
DIPNotificationService depends on all its collaborators as injected interfaces, including the reused RateLimiter abstractionNotificationService instantiating new SmsChannel() and a rate-limit check inline

M. Design Patterns

PatternProblem It SolvesWhere It AppearsWhen NOT to Use
StrategyChannels are genuinely mutually exclusive delivery alternativesNotificationChannel hierarchyN/A — clearly justified
Chain of ResponsibilityPreference → rate-limit → send is a real short-circuiting pipeline where any stage can stop the flowSequential checks inside NotificationService.notify(), or as explicit handler objects if the pipeline growsSkip the full handler-object machinery for just two or three checks — sequential guard clauses are equally honest and simpler
ObserverInternal system events (order shipped, password changed) should trigger notifications without the triggering code knowing about notifications at allAn event bus NotificationService subscribes toSkip if notifications are only ever triggered by explicit direct calls
Template Method (light)Render → check → send → record is the same shape across every channel, only the "send" step's mechanics differA shared base flow in NotificationService, with channel-specific behavior isolated to the Strategy, not a subclass hierarchyDon't build actual subclassing per channel for this — composition via Strategy already captures the variation cleanly

Explicitly avoided: Bridge pattern for category × channel (see I) — a plausible-looking fit that doesn't survive contact with what actually varies.

N. What Is Likely to Change?

BehaviorCurrent DesignAnticipated ChangeProtecting Abstraction
ChannelsEmail, SMS, Push, In-appWhatsApp, Slack, webhook callbacksNotificationChannel interface
Rate limit granularityPer (user, channel)Per (user, channel, category), or a global per-user cap across all channelsRateLimiter key composition, unchanged interface
Retry policyFixed exponential backoffChannel-specific backoff (SMS providers rate-limit differently than email)RetryScheduler configurable per channel
Trigger sourceInternal event busExternal webhook-triggered notifications from third-party integrationsObserver subscription point stays the same; only the publisher changes

O. Design Evolution

What stays stable: NotificationService.notify()'s signature. New channels, new preference granularity, and new retry policies are all additive.

P. API / Interface Design

interface NotificationChannel {
  DeliveryResult send(RenderedContent content, String recipientAddress);
}

class NotificationService {
  void notify(String userId, Category category, String templateId,
              Map<String,Object> params, List<ChannelType> channels, String idempotencyKey);
}

Ownership/validation notes: notify() runs preference → rate-limit checks per requested channel before rendering or sending anything on that channel — an opted-out or rate-limited channel is skipped cheaply, never rendered. The idempotencyKey is mandatory, not optional, given the at-least-once assumption in F.

Q. Pseudocode — Critical Logic

notify(userId, category, templateId, params, channels, idemKey):
  if dedupeCache.seen(idemKey): return                 // idempotent replay guard
  for channel in channels:
    if not userPreference.isAllowed(userId, channel, category): continue
    if not rateLimiter.tryAcquire(key=(userId, channel)): continue
    content = template.render(templateId, channel, params)
    result = channelImpl(channel).send(content, resolveAddress(userId, channel))
    if result.failed:
      retryScheduler.scheduleRetry(notification, attempt=1)
  dedupeCache.record(idemKey)

RetryScheduler.onRetryDue(notification, attempt):
  result = channelImpl(notification.channel).send(...)
  if result.failed:
    if attempt >= MAX_ATTEMPTS:
      deadLetterQueue.record(notification, result.error)
    else:
      scheduleRetry(notification, attempt + 1, delay=backoff(attempt))

R. End-to-End Use Case

"Order shipped" event fires
  │
  ▼
NotificationService.notify(userId, TRANSACTIONAL, "order_shipped", {...}, [EMAIL, PUSH], idemKey)
  │
  ▼
EMAIL: preference OK → rate limit OK → render → EmailChannel.send() → success
PUSH:  preference OK → rate limit EXCEEDED → skipped, no send attempted
  │
  ▼
(later) EmailChannel.send() had actually failed transiently
  │
  ▼
RetryScheduler.scheduleRetry(notification, attempt=1, delay=30s)
  │
  ▼
Retry succeeds → notification marked DELIVERED

S. Data Structures

StructureUsed ForWhyAlternative & Trade-off
Token bucket per (userId, channel)Rate limitingDirectly reuses Problem 3's TokenBucketLimiter — no new algorithm neededA fresh per-problem counter would duplicate logic already solved and tested elsewhere
Delay-ordered priority queue (or a scheduled-executor's timer wheel)RetryScheduler's pending retriesEfficiently pop the next due retry without scanning all pending onesA flat list with periodic full scans is simpler but wasteful at scale
Small LRU/TTL cache of seen idempotency keysDedup on retried notify() callsBounded memory while covering the realistic retry windowAn unbounded set is simpler but grows forever

T. Complexity Analysis

OperationTimeSpace
notify() per channelO(1) preference + rate-limit check, O(template size) renderO(1) per channel
RetryScheduler pop-next-dueO(log n) with a priority queueO(pending retries)

U. Testing Strategy

V. Interviewer Follow-Ups

Follow-UpTestingDesign ChangeClasses ChangedPattern/Trade-off
"Add a WhatsApp channel."OCP under a real new requirementNew WhatsAppChannel implementing the existing interfaceNew class onlyValidates the Strategy investment, identical to every other Strategy case in this document
"A user shouldn't get more than 3 notifications of any kind per hour, across all channels."Recognizing a cross-channel rate-limit dimension the current key doesn't captureAdd a second, coarser rate-limit check keyed by (userId) alone, evaluated alongside the per-channel oneRateLimiter usage, not its interfaceTwo independent rate limits composing cleanly — no redesign needed since RateLimiter was already generic
"How do you guarantee a user never gets the same notification twice from a retried delivery?"Idempotency discipline under at-least-once deliveryMandatory idempotency key + dedupe cache, exactly as in Splitwise's settleUpNotificationServiceSame lesson as Problem 4, reapplied in a different domain — a good cross-problem callback if raised unprompted
"The email provider is down for an hour."Failure isolation across channelsRetries for email queue up and back off; SMS/push channels are entirely unaffectedRetryScheduler, per-channel isolationOne channel's outage must not block or slow down other channels' delivery
"Scale to millions of notifications during a flash sale."LLD → HLD boundaryIn-process dispatch becomes an async queue/worker-pool architecture (or hands off to Problem 10's Pub-Sub design directly)None in-process — infra changeNaming that this problem's async dispatch queue is exactly Problem 10's domain at scale is a strong cross-reference

W. Concurrency

Shared mutable state: the rate limiter's per-key counters (already addressed in Problem 3), the dedupe cache, and the retry scheduler's pending-retry queue.

X. Persistence

Two different durability needs worth separating: (1) delivery records — an audit trail of what was sent, when, and its final status, useful for support/compliance, naturally append-only; (2) pending retry state — must survive a process restart (a retry scheduled for 5 minutes from now shouldn't vanish if the service redeploys), which argues for a durable queue (e.g. a database table or a message broker) rather than a purely in-memory timer. Treating pending retries as safely in-memory is the single most common shortcut worth flagging as a real gap.

Y. Scaling Boundary

LayerWhat Belongs Here
LLDNotificationService, Channel/Preference/RateLimiter/RetryScheduler classes, the pipeline ordering
HLDAn async message queue between trigger and dispatch (this document's Problem 10, applied directly), horizontally scaled worker pools per channel
Distributed SystemsExactly-once-effective delivery across a distributed retry/dedupe store, and coordinating rate limits across many dispatch workers (the same Redis-Lua-script pattern from Problem 3)

Z. Failure Scenarios

AA. Common Bad Designs

AB. Design Trade-Offs

DecisionOption AOption BTrade-off
Category/channel modelingBridge (two independent hierarchies)Template + parameter lookup (chosen)Bridge is "more correct" GoF-purity but adds a hierarchy for variation that's actually just data, not behavior
Pending retry storageIn-memory timerDurable queue/tableIn-memory is simpler and faster but loses scheduled retries on restart; durable survives restarts at the cost of a persistence dependency
Dispatch modelSynchronous inlineAsync queue between trigger and sendSync is simpler for low volume; async is required once burst traffic (flash sales, mass campaigns) is in scope

AC. Interviewer Evaluation

AD. Interview Simulation

Interviewer: Design a notification system supporting email, SMS, and push.
Candidate: I'll make delivery medium a Strategy — a NotificationChannel interface with one implementation per medium. Quick question: should this also enforce user preferences and rate limits, or is that out of scope?
Interviewer: Both are in scope.
Candidate: Then the send pipeline is preference-check, then rate-limit-check, then render-and-send — each stage can short-circuit the rest. For rate limiting specifically, I'd reuse the same token-bucket abstraction I'd use for an API rate limiter, just keyed by user and channel instead of by client.
Interviewer: A delivery attempt times out — did it send or not?
Candidate: That's treated as unknown, and retried — which means every notification needs an idempotency key so a provider that actually did send it the first time doesn't double-deliver on retry.
Interviewer: Isn't category (promotional vs. transactional) and channel (email vs. SMS) exactly the kind of two-dimensional variation Bridge is meant for?
Candidate: It looks that way, but category only changes which template and default channels get used — it doesn't change how any channel actually sends. Since there's no method that needs to vary by category on the channel side, Bridge would add a second hierarchy to solve something a template lookup already solves more simply.
Interviewer: How would this handle a flash-sale-sized burst of notifications?
Candidate: I wouldn't call channel providers synchronously from the event handler — I'd push onto an async queue and let a worker pool drain it, which is really just this document's Pub-Sub design applied directly to this problem's dispatch step.

AE. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
LLDHub — Notification System LLD DesignNotificationChannel Strategy, NotificationService orchestrator, UserPreference, RateLimiter (token bucket per user/channel in Redis), RetryScheduler with exponential backoff, explicit Chain-of-Responsibility framing for preference→rate-limit→sendExtremely close match to this document's design — validates the Strategy + CoR-pipeline + reused-rate-limiter approach directlyDoesn't explicitly discuss the Bridge-vs-parameterization question, or address pending-retry durability across restartsThe token-bucket-per-(user,channel)-in-Redis detail and the named CoR pipeline framing, both adopted directly in this document
General LLD-interview pattern guides (multiple sources, cross-referenced)Consistently name Strategy for channels and Observer for event-triggered sends as the two anchor patternsConvergent agreement across sources on the core two patterns increases confidence they're the right baselineMost treat rate limiting and idempotency as afterthoughts rather than first-class requirementsCross-check only — the convergence itself is the useful signal, more than any single source's depth

AF. Interview Cheat Sheet

ProblemNotification System
DifficultyIntermediate → Advanced
Core requirementsMulti-channel dispatch, preferences, rate limiting, retry with backoff
Key clarificationsWhich channels? Event-triggered or direct call? Rate-limit granularity? Retry policy? At-least-once or best-effort?
Core entitiesNotification, NotificationTemplate, UserPreference, NotificationChannel, RateLimiter (reused), RetryScheduler, DeadLetterQueue, NotificationService
Key relationshipsChannel is a Strategy; Bridge considered and rejected for category×channel; pipeline is a light Chain of Responsibility
Key patternsStrategy (channels), Chain of Responsibility (pipeline), Observer (event triggers), light Template Method (shared flow)
Most important decisionIdempotency key for at-least-once delivery, and reusing Problem 3's rate limiter rather than reinventing it
Likely-changing behaviorNew channels, rate-limit granularity, retry policy per channel, trigger source
Concurrency concernAtomic dedupe-check-and-record; per-channel failure isolation; burst absorption via async queue
Top follow-upsNew channel addition, cross-channel rate cap, idempotent retries, single-channel outage isolation, flash-sale-scale burst
Common mistakesBridge over-engineering; reinvented rate limiting; missing idempotency key; synchronous dispatch on the trigger path
What NOT to over-engineerDon't build a full distributed message broker live — name Problem 10's Pub-Sub design as the scale-up answer

AG. Full Implementation

A complete, compilable reference implementation of every class in the diagram above (composition, inheritance, interface realization, and dependency-injected strategies all preserved per section I), with a runnable demo exercising the primary flow — in Java, C++, JavaScript, and TypeScript.

// Notification System -- full LLD implementation with demo.
// Single-file Java translation of the Mermaid class diagram in section J.
import java.util.*;

public class p7 {

    enum Category { TRANSACTIONAL, PROMOTIONAL, SECURITY_ALERT }
    enum ChannelType { EMAIL, SMS, PUSH }
    enum Status { PENDING, SENT, FAILED, DEAD_LETTERED }

    // ---------- Notification (data record) ----------
    static final class Notification {
        final String id;
        final String userId;
        final Category category;
        final String templateId;
        final Map<String, Object> params;
        final List<ChannelType> channels;
        final String idempotencyKey;
        Status status = Status.PENDING;
        // Retry context: populated by NotificationService just before a scheduleRetry() call
        // so RetryScheduler can re-attempt delivery on exactly the medium that failed.
        ChannelType retryChannel;
        RenderedContent retryContent;
        String retryRecipient;

        Notification(String id, String userId, Category category, String templateId,
                      Map<String, Object> params, List<ChannelType> channels, String idempotencyKey) {
            this.id = id; this.userId = userId; this.category = category; this.templateId = templateId;
            this.params = params; this.channels = channels; this.idempotencyKey = idempotencyKey;
        }
    }

    /** Content once rendered from a template -- has no lifecycle of its own. */
    static final class RenderedContent {
        final String subject;
        final String body;
        RenderedContent(String subject, String body) { this.subject = subject; this.body = body; }
        public String toString() { return (subject != null ? "[" + subject + "] " : "") + body; }
    }

    static final class DeliveryResult {
        final boolean failed;
        final String error;
        static DeliveryResult ok() { return new DeliveryResult(false, null); }
        static DeliveryResult fail(String error) { return new DeliveryResult(true, error); }
        private DeliveryResult(boolean failed, String error) { this.failed = failed; this.error = error; }
    }

    // ---------- NotificationTemplate ----------
    /** Content pattern per category+channel; substitutes {{placeholders}} with params at render time. */
    static final class NotificationTemplate {
        final String id;
        final Map<ChannelType, String> subjectPatterns = new HashMap<>(); // optional, e.g. email only
        final Map<ChannelType, String> bodyPatterns = new HashMap<>();

        NotificationTemplate(String id) { this.id = id; }

        NotificationTemplate withBody(ChannelType channel, String pattern) {
            bodyPatterns.put(channel, pattern);
            return this;
        }
        NotificationTemplate withSubject(ChannelType channel, String pattern) {
            subjectPatterns.put(channel, pattern);
            return this;
        }

        RenderedContent render(ChannelType channel, Map<String, Object> params) {
            String bodyPattern = bodyPatterns.get(channel);
            if (bodyPattern == null) throw new IllegalStateException("No template body for channel " + channel);
            String subjectPattern = subjectPatterns.get(channel);
            return new RenderedContent(substitute(subjectPattern, params), substitute(bodyPattern, params));
        }

        private String substitute(String pattern, Map<String, Object> params) {
            if (pattern == null) return null;
            String out = pattern;
            for (Map.Entry<String, Object> e : params.entrySet()) {
                out = out.replace("{{" + e.getKey() + "}}", String.valueOf(e.getValue()));
            }
            return out;
        }
    }

    // ---------- UserPreference ----------
    /** Per-user opt-in/opt-out. Default is allowed unless explicitly opted out. */
    static final class UserPreference {
        // key: userId|channel|category -> enabled
        private final Map<String, Boolean> overrides = new HashMap<>();

        private String key(String userId, ChannelType channel, Category category) {
            return userId + "|" + channel + "|" + category;
        }

        void setPreference(String userId, ChannelType channel, Category category, boolean enabled) {
            overrides.put(key(userId, channel, category), enabled);
        }

        boolean isAllowed(String userId, ChannelType channel, Category category) {
            return overrides.getOrDefault(key(userId, channel, category), true); // opt-out model
        }
    }

    // ---------- RateLimiter (reused from Problem 3) ----------
    /** Fixed-window limiter: caps sends per key within a rolling window. */
    static final class RateLimiter {
        private final int maxPerWindow;
        private final long windowMillis;
        private final Map<String, Deque<Long>> history = new HashMap<>();

        RateLimiter(int maxPerWindow, long windowMillis) {
            this.maxPerWindow = maxPerWindow; this.windowMillis = windowMillis;
        }

        boolean tryAcquire(String key) {
            long now = System.currentTimeMillis();
            Deque<Long> timestamps = history.computeIfAbsent(key, k -> new ArrayDeque<>());
            while (!timestamps.isEmpty() && now - timestamps.peekFirst() > windowMillis) timestamps.pollFirst();
            if (timestamps.size() >= maxPerWindow) return false;
            timestamps.addLast(now);
            return true;
        }
    }

    // ---------- NotificationChannel (Strategy interface) ----------
    interface NotificationChannel {
        DeliveryResult send(RenderedContent content, String recipientAddress);
    }

    static final class EmailChannel implements NotificationChannel {
        public DeliveryResult send(RenderedContent content, String recipientAddress) {
            System.out.println("  [EMAIL -> " + recipientAddress + "] " + content);
            return DeliveryResult.ok();
        }
    }

    /** Simulates a transient network failure: fails once per recipient, then succeeds. */
    static final class SMSChannel implements NotificationChannel {
        private final Set<String> failedOnce = new HashSet<>();
        public DeliveryResult send(RenderedContent content, String recipientAddress) {
            if (failedOnce.add(recipientAddress)) {
                System.out.println("  [SMS -> " + recipientAddress + "] transient failure");
                return DeliveryResult.fail("carrier timeout");
            }
            System.out.println("  [SMS -> " + recipientAddress + "] " + content);
            return DeliveryResult.ok();
        }
    }

    /** Simulates a permanently broken provider, to demonstrate dead-lettering. */
    static final class PushChannel implements NotificationChannel {
        public DeliveryResult send(RenderedContent content, String recipientAddress) {
            System.out.println("  [PUSH -> " + recipientAddress + "] provider unavailable");
            return DeliveryResult.fail("device token invalid");
        }
    }

    // ---------- DeadLetterQueue ----------
    static final class DeadLetterQueue {
        private final List<String> entries = new ArrayList<>();
        void record(Notification notification, String error) {
            entries.add(notification.id + " (" + notification.retryChannel + "): " + error);
            notification.status = Status.DEAD_LETTERED;
        }
        List<String> entries() { return entries; }
    }

    // ---------- RetryScheduler ----------
    /** Re-attempts failed sends with backoff, up to MAX_ATTEMPTS, then dead-letters. */
    static final class RetryScheduler {
        private static final int MAX_ATTEMPTS = 3;
        private final Map<ChannelType, NotificationChannel> channels;
        private final DeadLetterQueue deadLetterQueue;

        RetryScheduler(Map<ChannelType, NotificationChannel> channels, DeadLetterQueue deadLetterQueue) {
            this.channels = channels; this.deadLetterQueue = deadLetterQueue;
        }

        /** In production this enqueues a delayed timer job; here we simulate the delay synchronously for the demo. */
        void scheduleRetry(Notification notification, int attempt) {
            long delayMs = backoff(attempt);
            System.out.println("  retry scheduled for " + notification.id + " attempt=" + attempt + " delay=" + delayMs + "ms");
            onRetryDue(notification, attempt);
        }

        private void onRetryDue(Notification notification, int attempt) {
            NotificationChannel channel = channels.get(notification.retryChannel);
            DeliveryResult result = channel.send(notification.retryContent, notification.retryRecipient);
            if (result.failed) {
                if (attempt >= MAX_ATTEMPTS) {
                    deadLetterQueue.record(notification, result.error);
                } else {
                    scheduleRetry(notification, attempt + 1);
                }
            } else {
                notification.status = Status.SENT;
            }
        }

        private long backoff(int attempt) { return 100L * (1L << attempt); } // exponential backoff
    }

    // ---------- NotificationService (facade) ----------
    static final class NotificationService {
        private final Map<String, NotificationTemplate> templates;
        private final UserPreference userPreference;
        private final RateLimiter rateLimiter;
        private final Map<ChannelType, NotificationChannel> channels;
        private final RetryScheduler retryScheduler;
        private final Set<String> dedupeCache = new HashSet<>();
        private int counter = 0;

        NotificationService(Map<String, NotificationTemplate> templates, UserPreference userPreference,
                             RateLimiter rateLimiter, Map<ChannelType, NotificationChannel> channels,
                             RetryScheduler retryScheduler) {
            this.templates = templates; this.userPreference = userPreference; this.rateLimiter = rateLimiter;
            this.channels = channels; this.retryScheduler = retryScheduler;
        }

        Notification notify(String userId, Category category, String templateId,
                             Map<String, Object> params, List<ChannelType> requestedChannels, String idempotencyKey) {
            Notification notification = new Notification("N" + (++counter), userId, category, templateId,
                    params, requestedChannels, idempotencyKey);

            if (dedupeCache.contains(idempotencyKey)) { // idempotent replay guard
                System.out.println("Skipping duplicate notify() for idemKey=" + idempotencyKey);
                return notification;
            }

            NotificationTemplate template = templates.get(templateId);
            for (ChannelType channel : requestedChannels) {
                if (!userPreference.isAllowed(userId, channel, category)) {
                    System.out.println("  " + channel + " skipped: user opted out for " + category);
                    continue;
                }
                if (!rateLimiter.tryAcquire(userId + "|" + channel)) {
                    System.out.println("  " + channel + " skipped: rate limit exceeded");
                    continue;
                }
                RenderedContent content = template.render(channel, params);
                String recipient = resolveAddress(userId, channel);
                DeliveryResult result = channels.get(channel).send(content, recipient);
                if (result.failed) {
                    notification.status = Status.FAILED;
                    notification.retryChannel = channel;
                    notification.retryContent = content;
                    notification.retryRecipient = recipient;
                    retryScheduler.scheduleRetry(notification, 1);
                } else if (notification.status != Status.SENT) {
                    notification.status = Status.SENT;
                }
            }
            dedupeCache.add(idempotencyKey);
            return notification;
        }

        private String resolveAddress(String userId, ChannelType channel) {
            switch (channel) {
                case EMAIL: return userId + "@example.com";
                case SMS: return "+1-555-" + userId;
                case PUSH: return "device-token-" + userId;
                default: throw new IllegalArgumentException("Unknown channel: " + channel);
            }
        }
    }

    // ---------- Demo ----------
    public static void main(String[] args) {
        NotificationTemplate welcome = new NotificationTemplate("welcome")
                .withSubject(ChannelType.EMAIL, "Welcome, {{name}}!")
                .withBody(ChannelType.EMAIL, "Hi {{name}}, thanks for joining.")
                .withBody(ChannelType.SMS, "Hi {{name}}, welcome aboard!")
                .withBody(ChannelType.PUSH, "Welcome, {{name}}!");
        Map<String, NotificationTemplate> templates = new HashMap<>();
        templates.put("welcome", welcome);

        UserPreference preferences = new UserPreference();

        RateLimiter rateLimiter = new RateLimiter(5, 24L * 60 * 60 * 1000); // 5 per day per (user, channel)

        Map<ChannelType, NotificationChannel> channels = new HashMap<>();
        channels.put(ChannelType.EMAIL, new EmailChannel());
        channels.put(ChannelType.SMS, new SMSChannel());
        channels.put(ChannelType.PUSH, new PushChannel());

        DeadLetterQueue dlq = new DeadLetterQueue();
        RetryScheduler retryScheduler = new RetryScheduler(channels, dlq);

        NotificationService service = new NotificationService(templates, preferences, rateLimiter, channels, retryScheduler);

        Map<String, Object> params = new HashMap<>();
        params.put("name", "Alice");

        System.out.println("First notify (SMS will fail once then retry-succeed; PUSH will exhaust retries -> DLQ):");
        service.notify("u1", Category.TRANSACTIONAL, "welcome", params,
                List.of(ChannelType.EMAIL, ChannelType.SMS, ChannelType.PUSH), "idem-1");

        System.out.println("\nReplaying same idempotencyKey (should be skipped):");
        service.notify("u1", Category.TRANSACTIONAL, "welcome", params,
                List.of(ChannelType.EMAIL, ChannelType.SMS, ChannelType.PUSH), "idem-1");

        System.out.println("\nOpting out u2 from SMS/PROMOTIONAL, then attempting anyway:");
        preferences.setPreference("u2", ChannelType.SMS, Category.PROMOTIONAL, false);
        service.notify("u2", Category.PROMOTIONAL, "welcome", params, List.of(ChannelType.SMS), "idem-2");

        System.out.println("\nDead letter queue entries: " + dlq.entries());
    }
}