Cruxly

Cruxly › Low-level design › LLD Exercises

Notification System (LLD)

one send() call, several channels, and a retry policy that must not duplicate

Open the interactive lesson

Prompt: design a notification system. Callers call send(userId, message) and the system delivers it via the user's preferred channel (email, SMS, push) with fallback to another channel on failure, retrying transient failures without sending the same notification twice.

45 minutes. The interesting part is not the channels. It's that "fallback" and "retry" are two different mechanisms often collapsed into one, and collapsing them is the bug.

Applying the five moves: nouns are Notification, Channel, and an IdempotencyKey — the last one is easy to forget until move 3 forces the question of how "without sending twice" is actually enforced. The invariant is "a given notification is delivered at most once per channel attempt, and retries never bypass the idempotency check" — stating this is what surfaces that retry and fallback need to share the same dedup mechanism rather than each inventing their own. The interface seam is Channel (email/SMS/push as implementations of one interface), because the prompt names three channels explicitly — a textbook Strategy signal. Concurrency: if send() can be called for the same logical notification from more than one caller (a retry from an upstream queue plus this system's own retry, for instance), the idempotency check itself has to be atomic, or two "at most once" checks can both pass.

Scope, and the two mechanisms that get conflated

In scope: channel abstraction (email/SMS/push), per-user channel preference and fallback order, retry on transient failure, idempotent delivery. Out of scope (say it): the actual provider integrations (Twilio, SES — external systems, name them and move on), notification content/templating, user preference management UI.

Retry and fallback are different mechanisms answering different questions, and the exercise is keeping them separate:

Collapsing them — retrying by falling back, or falling back by retrying the same channel — produces the exact bug users report: multiple copies of one notification arriving through different channels, or an alert never arriving because retries never gave up on a channel that's out of retries and not actually down. Stating this distinction before writing a class is the strongest opening move, because it's the one thing every unstructured implementation gets wrong.

The classes — Strategy for channels, a policy object for the rest

One interface, one implementation per channel — this is Strategy, named after establishing the problem, not before.

public interface NotificationChannel {
    /** Throws TransientException for a retryable failure, PermanentException
     *  for one retrying will never fix (bad phone number, revoked push token). */
    void send(String userId, String message) throws TransientException, PermanentException;
    ChannelType type();
}

public class EmailChannel implements NotificationChannel {
    public void send(String userId, String message) throws TransientException, PermanentException {
        try {
            emailProvider.send(resolveEmail(userId), message);
        } catch (ProviderTimeoutException e) {
            throw new TransientException(e);          // provider hiccup: worth retrying
        } catch (InvalidAddressException e) {
            throw new PermanentException(e);           // bad address: retrying never helps
        }
    }
    public ChannelType type() { return ChannelType.EMAIL; }
}
// SmsChannel, PushChannel: same shape, different provider and exception mapping

The permanent/transient distinction on the exception itself is the load-bearing detail. Without it, the dispatcher has no way to know whether to retry the same channel or give up and fall back — it would have to guess from the exception's class name or message text, which breaks the moment a provider library changes its exceptions.

public class NotificationDispatcher {
    private final Map<ChannelType, NotificationChannel> channels;
    private final RetryPolicy retryPolicy;             // e.g. 3 attempts, exponential backoff
    private final IdempotencyStore sentLog;             // (userId, notificationId) -> already sent?

    public void send(String userId, String notificationId, String message, List<ChannelType> preferenceOrder) {
        if (sentLog.alreadySent(notificationId)) return;      // idempotent at the top, before any channel

        for (ChannelType type : preferenceOrder) {
            NotificationChannel channel = channels.get(type);
            if (attemptWithRetry(channel, userId, message)) {
                sentLog.markSent(notificationId, userId, type);
                return;                                        // fallback stops at the first success
            }
            // channel exhausted its retries or hit a permanent failure — fall back to the next
        }
        sentLog.markFailed(notificationId, userId);            // every channel exhausted
    }

    private boolean attemptWithRetry(NotificationChannel channel, String userId, String message) {
        for (int attempt = 1; attempt <= retryPolicy.maxAttempts(); attempt++) {
            try {
                channel.send(userId, message);
                return true;
            } catch (PermanentException e) {
                return false;                                   // do not retry; move to fallback immediately
            } catch (TransientException e) {
                if (attempt == retryPolicy.maxAttempts()) return false;
                sleepBackoff(retryPolicy.backoffFor(attempt));
            }
        }
        return false;
    }
}

Notice the shape: retry is the inner loop, over one channel; fallback is the outer loop, over channels. A PermanentException short-circuits the inner loop immediately — there is no reason to retry a bad phone number three times before falling back to email — while a TransientException exhausts its retry budget on the same channel before the outer loop moves on. This nesting is the actual answer to the scoping question in the first section, expressed as code rather than as prose.

Idempotency, and why it has to sit above the channel loop

The idempotency check happens once, before the channel loop, keyed by a caller-supplied notificationId — not inside each channel, and not keyed by channel. If idempotency were checked per-channel, a caller retrying the whole send() call after a timeout (the caller never learned whether it succeeded) could still get a second notification through a different channel than the first attempt used, because "have I sent via SMS" and "have I sent via email" are different keys. The check has to answer "has this logical notification been delivered by any means", which only makes sense one level above the channel abstraction.

This is the same idempotency-key discipline as the payment and ad-click exercises elsewhere in this track, applied to a caller that can retry the entire operation, not just one network call. The notificationId must be generated by the caller, not the dispatcher, for the identical reason a payment's idempotency key is client-generated: a caller retrying after a timeout must send the same key both times for the check to recognize it as a duplicate.

Recording "sent" has its own race worth naming. Two concurrent calls to send() with the same notificationId (a caller's own retry racing its original, timed-out request) must not both pass the idempotency check and both dispatch — sentLog.alreadySent() followed later by sentLog.markSent() is itself a check-then-act pair, the same shape as the rate limiter's core race, and needs the same fix: an atomic claim (markInProgress that fails if already claimed) rather than a plain read-then-write.

Follow-ups they will ask

"What if the user's preferred channel list changes mid-retry?" Snapshot the preference order at the start of send(), not re-read on every fallback step — a preference change mid-flight should affect the next notification, not retroactively alter one already in progress, the same "decide once, apply consistently" principle as pinning a workflow's inputs at the start of a saga elsewhere in this track.

"Batch notifications — send the same message to ten thousand users." This is a fan-out problem layered on top of the per-user dispatcher, not a change to it: a batch coordinator enqueues one send() per user (each with its own notificationId) onto a worker pool, and the per-user dispatcher above is completely unaware it's part of a batch. Keeping the two concerns separate means batch failure handling (partial batch failure, batch-level retry limits) doesn't leak into the single-notification logic that already has its own correct retry/fallback story.

"Add a 'do not disturb' quiet-hours policy per channel." This is a filter applied before the dispatcher is called, not a new branch inside it — a push notification during quiet hours should either queue for later or downgrade to a channel the user allows during quiet hours (e.g., email only), and modelling this as a pre-dispatch policy check keeps the retry/fallback core, which is already correct, untouched by a concern that's really about when to call send(), not how it sends.

How to say it in the interview

"Channels vary by user preference and provider, so that's Strategy — one interface, one implementation per channel. But fallback and retry are not the same thing: retry is the same channel again after a transient failure, fallback is a different channel after that channel is exhausted, and conflating them is how a user gets three copies of the same alert."

Checklist

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons