Intermediate30 min readMachine Coding Practicelive prototype

Notification system

The sender must not know how — or even whether — the message arrives. OrderService publishes “order shipped” and walks away; something else decides that this user wants SMS and push but not email, renders a different body for each, and then deals with the fact that delivery fails. All the interesting code lives after send() returns.

The idea

What it is

“Design a notification system.” Most candidates hear “send an email” and start writing an EmailSender. That is the wrong end of the problem. Sending is the easy part — a library call. The round is about everything around it.

Picture what actually happens. An order ships. Somewhere in the warehouse service a line of code runs, and a few seconds later a customer's phone buzzes. Between those two moments, three questions were answered: does this person want to hear about this at all, on which channel, and what happens if the phone network drops the message. The warehouse service knows none of that and must never learn it.

«event source» OrderService AuthService MarketingJob they publish events — no address, no channel, no subject line NotificationService + publish(event) resolve → render → dedup → enqueue UserPreference who + whether + quiet hours Template what to say, per channel both are rows, not code Queue OTP · high shipped · normal promo · low Worker pool drains the queue off the request path EmailChannel SmsChannel PushChannel WhatsAppChannel one interface, four impls 📧 💬 🔔 💚 RETRYABLE → back to the queue with backoff DeadLetterQueue permanent, or attempts exhausted Every noun on this picture is a class. Draw it in minute 6 and the rest of the hour writes itself.
Follow one event left to right, then follow the red dashed lines back. Those two returns — retry into the queue, or fall into the dead-letter queue — are where this problem actually lives.

The whole system in three sentences

A publisher emits an event that names what happened, never how to tell anyone. The NotificationService asks the preferences which channels this user wants for this event, and the templates what the body should say on each of those channels. Then it enqueues — and a worker deals with the fact that sending is unreliable: retry with backoff and jitter if the failure looks temporary, dead-letter it if it does not.

What is actually being graded

  1. Does the publisher stay ignorant? If OrderService ever touches an email address, a phone number or the string "SMS", you have already lost the main point.
  2. Is DeliveryResult more than a boolean? Success, retryable failure and permanent failure are three different things, and treating the last two the same is a production incident.
  3. Is the retry bounded, backed off, and jittered? “I'd retry” is not an answer. “Exponential backoff with jitter, capped attempts, then a dead-letter queue” is.
  4. Is delivery idempotent? Retries mean at-least-once. Without a stable key and a dedup check, every retry storm double-sends.
  5. Does it run? A fake channel you can tell to fail, a demo that prints one message retrying and another dying immediately, and a dead-letter queue you can read at the end.

Mechanics

How it works

Step 1 · Clarify — 5 minutes

  • Which channels? — email, SMS, push, and “we might add WhatsApp”. That last clause is the interviewer handing you the extensibility question. Take it.
  • Can a user turn things off? — yes, per event type and per channel, plus quiet hours. This is the difference between a fan-out toy and a notification system.
  • What happens when a provider is down? — say the word retry and then immediately say bounded, backed off, jittered, dead-lettered. Four words, and you have covered the whole second half of the hour.
  • Is delivery synchronous? — no. Checkout must not wait on Twilio. Enqueue and return.
  • Do we need delivery receipts? — yes for the design, because “did it arrive?” is a support question, so notifications carry a status.
  • Out of scope: writing the actual SMTP/APNs client, the templating language itself, user auth, and the analytics warehouse. Say it in one sentence and move on.
✓ IN SCOPE — say these out loud in minute 3 publish(event) — one call, no channel named preferences: user × eventType × channel quiet hours + per-category opt-out templates per (eventType, channel, locale) async queue + priority lanes Channel interface + 4 implementations DeliveryResult: ok / retryable / permanent backoff + jitter + cap + dead-letter queue idempotency key + status tracking ✗ OUT OF SCOPE — one sentence, then move on a real SMTP / APNs / Twilio client the templating language itself user accounts and authentication analytics warehouse / reporting campaign targeting and segmentation writing the copy, in any language every one of these is a real system — saying so is what buys you the 40 minutes you need for retries and dedup
Notice which lines are orange. Those three are the ones that separate this from a fan-out exercise — put them on the board early so the interviewer knows you are going there.

Do not spend the hour on fan-out plumbing

It is very easy to build a tidy for (channel : channels) channel.send(...) loop, feel good, and run out of clock. That loop is ten minutes of work. The round is decided by what happens when one of those send calls fails, and by whether the sender was ever allowed to know the channel existed.

Step 2 · An event is not a notification

This is the single distinction the whole design hangs on. An event is a fact about the world: order ORD-91 shipped, for user U-2. A notification is a decision about a person: send U-2 this exact SMS body. The order service is qualified to state the fact. It is not qualified to make the decision, and it must not try.

✗ THE SENDER KNOWS THE CHANNEL OrderService emailClient.send(user.email, subject, body) OrderService now owns: the email address · the subject line the SMTP client · retries on failure the opt-out check · quiet hours add WhatsApp → edit OrderService, PaymentService, ShippingService, … cost = every publisher × every channel and marketing cannot ship anything without you ✓ THE SENDER STATES A FACT OrderService publish(new OrderShipped("ORD-91", "U-2")) that is the entire payload. downstream: preferences templates channels the event has no address, no subject, and never the word “SMS” add WhatsApp → 1 class + preference rows cost = 0 edits to any publisher marketing flips rows in a table and ships
Count the edits on each side for “also send it on WhatsApp”. Left: every service that publishes anything. Right: one new class and some rows. That arithmetic is Open/Closed (OCP) with a price tag attached.
the seam, in full
// The publisher. It states a fact and stops.
class OrderService {
    private final NotificationService notifier;

    void markShipped(Order order) {
        shipments.record(order);
        notifier.publish(new Event(
            EventType.ORDER_SHIPPED,
            order.userId(),
            order.id(),                       // the entity, for the idempotency key
            Map.of("orderId", order.id())));  // template placeholders, nothing more
    }
}

// The decision maker. It is the ONLY class that knows channels exist.
class NotificationService {
    List<Notification> publish(Event event) {
        List<Notification> out = new ArrayList<>();
        for (ChannelType type : preferences.channelsFor(event.userId(), event.type())) {
            String body = templates.render(event.type(), type, event.data());
            Notification n = new Notification(
                event.userId() + ":" + event.type() + ":" + event.entityId(),  // dedup key
                event.userId(), type, body, Priority.of(event.type()));
            if (dedup.seen(n.key())) continue;   // at-least-once needs this
            queue.offer(n);                      // enqueue — do NOT send here
            out.add(n);
        }
        return out;
    }
}

Why the event carries an entity id

OrderShipped(orderId, userId) looks like it carries the order id only for the template. It does not — the order id is what makes the idempotency key stable across retries and duplicate publishes. userId + eventType + entityId is the same string every time this fact is restated, which is exactly what you need in Step 6.

Step 3 · Preferences decide who, and whether

One event does not mean one notification. It means zero or more, and the number is decided by data. The lookup is a three-part key — user, event type, channel — and the answer is a boolean.

UserPreference(userId, eventType, channel, enabled) — one row per cell pill OrderShipped OtpRequested PromoBlast E / S / P U-1 📧 on 💬 off 🔔 on 📧 on 💬 on 🔔 off 📧 on 💬 off 🔔 off U-2 📧 off 💬 on 🔔 on 📧 off 💬 on 🔔 on 📧 off 💬 off 🔔 off U-3 📧 on 💬 on 🔔 on 📧 on 💬 on 🔔 on 📧 on 💬 off 🔔 on U-2 · OrderShipped → SMS + push = 2 not 3. the publisher never knew either way. Quiet hours 22:00–07:00 are a second filter on top of the matrix: transactional (OTP, fraud alert) → ignores quiet hours, always sends promotional → held until morning, or dropped if it has expired by then
Every green pill is a row in a table. Read the highlighted cell: the same publish() call produces two notifications for U-2 and three for U-3, and no code anywhere had to change to make that true.
  • Default to sensible, not to silent. A missing row should mean on for transactional and off for promotional. Say which default you picked and why — it is a real product decision and interviewers notice when you make it deliberately.
  • A global unsubscribe outranks everything. One flag on the user that short-circuits the whole matrix, checked first. Legally you want exactly one place that can be wrong.
  • Quiet hours are per user timezone, not server time. One extra field, and forgetting it is how you wake somebody at 3am.
  • Never let quiet hours suppress an OTP. Category matters: transactional bypasses, promotional does not. Encode the category on the event type, not in an if in the service.

Step 4 · Templates are data too — and they are per channel

The same fact reads completely differently in an inbox and on a lock screen. An email can be four paragraphs with a tracking link. An SMS gets 160 characters and costs real money per segment beyond that. A push notification is a title plus about forty characters of body before the phone truncates it for you. So the template key is not eventType — it is (eventType, channel, locale).

OrderShipped orderId = "ORD-91" userId = "U-2" one event, one set of placeholder values Template rows — key: (eventType, channel, locale) ORDER_SHIPPED EMAIL · en "Your order {orderId}…" ORDER_SHIPPED SMS · en "{orderId} shipped." ORDER_SHIPPED PUSH · en "On its way 📦" 📧 EMAIL — no practical limit Your order ORD-91 has shipped and is on its way. Track it here: sub.rt/ORD-91 💬 SMS — 160 chars, billed per segment ORD-91 shipped. Track: sub.rt/ORD-91 36/160 — one segment, one charge 🔔 PUSH — title + a short line On its way 📦 Order ORD-91 has shipped A new event type is a new row. A new language is a new row. Neither is a deploy — exactly the move the coffee machine made with its recipes.
Three rows, one event, three bodies. Look at the 36/160 counter: the SMS template is short on purpose, because writing one long body and truncating it in code produces sentences that end mid-word. This is the same “the variation is data, not classes” move Coffee Machine made with recipes.

Say this and the templating question is closed

“Templates are rows keyed by event type, channel and locale, with {placeholder} substitution. Adding an event type or a language is a row, not a release. And the renderer is per-channel, so the SMS copy is written short rather than clipped short.” That is fifteen seconds and it covers internationalisation before they ask.

Step 5 · Channels behind one interface

Channel.send(Notification) → DeliveryResult. Four implementations, one method. Adding WhatsApp is a new class and some preference rows — the service does not change, because the service never names a channel. This is Strategy, and the tell that you got it right is that there is no switch on a channel string anywhere: the registry is a map from ChannelType to Channel, populated once at startup.

NotificationService - prefs : PreferenceStore - templates : TemplateStore - dedup : DedupStore + publish(Event) : int never names a channel NotificationQueue ordered by (priority, readyAt) + offer(n) / + poll() OTP never waits behind promo Dispatcher «worker» + runOnce() poll → channel.send → route the result: ack / retry / DLQ Channel «interface» + type() : ChannelType + send(n) : DeliveryResult registry map EmailChannel SmsChannel PushChannel WhatsAppChannel a 5th channel is a 5th box — nothing to the left changes DeliveryResult «sealed» Success(providerId) Retryable(reason) → backoff Permanent(reason) → DLQ Notification - key : String «idempotency» - userId, channel, body - priority, attempt, status RetryPolicy - baseDelay, maxDelay, maxAttempts + delayFor(attempt) — 2^n + jitter injectable, so tests are deterministic DeadLetterQueue + add(notification, reason) a human or a batch job reads this PreferenceStore · TemplateStore both back onto plain rows
Two boxes carry the round. DeliveryResult with three variants, and RetryPolicy as an injected object rather than a hardcoded Thread.sleep. Everything else here is bookkeeping. Notation: Class diagrams.

The thing beginners miss is that channels are not interchangeable in behaviour, only in shape. SMS has a hard 160-character segment and a per-message cost. Push needs a device token that goes stale the moment the user reinstalls the app. Email bounces — sometimes because the mailbox is full and sometimes because the address does not exist, and those two are not the same event. So a boolean return type throws away the only information the caller needs.

channel.send(n) returns DeliveryResult SUCCESS provider accepted it status → SENT · dedup key stored done ✓ await delivery receipt RETRYABLE timeout · 503 · 429 rate limited the world might be fine in 2 seconds backoff queue attempt++ · readyAt = now + d attempt > max → DLQ PERMANENT invalid number · unsubscribed hard bounce · dead device token dead-letter queue immediately · zero retries someone fixes the data retrying a permanent failure: it can never succeed, it burns quota forever, and it is a real incident a boolean cannot tell these apart
The dashed red curve is the mistake. “Invalid phone number” will still be an invalid phone number after eight retries — the only thing you achieve is spending your provider quota on a message that cannot be delivered.
the three-way result
sealed interface DeliveryResult {
    record Success(String providerId) implements DeliveryResult {}
    record Retryable(String reason) implements DeliveryResult {}   // timeout, 503, 429
    record Permanent(String reason) implements DeliveryResult {}   // bad number, unsubscribed
}

class SmsChannel implements Channel {
    public DeliveryResult send(Notification n) {
        if (!isValidNumber(n.destination()))
            return new DeliveryResult.Permanent("invalid number");   // NEVER retry this
        String body = n.body();
        if (body.length() > 160) body = body.substring(0, 157) + "...";  // 1 segment, 1 charge
        try {
            return new DeliveryResult.Success(provider.send(n.destination(), body));
        } catch (RateLimitedException | TimeoutException | ServerBusyException e) {
            return new DeliveryResult.Retryable(e.getMessage());     // the world may recover
        }
    }
}

The classification lives in the channel, not the dispatcher

Only the SMS channel knows that a Twilio 21211 means bad number and a 429 means slow down. If the dispatcher has to parse provider error codes, you have leaked one provider's vocabulary into shared code — and the second provider will not use the same numbers. The channel translates; the dispatcher only reads the three-way answer.

Step 6 · Retry, backoff, jitter, dead-letter — the second act

A send fails. You do not fail the caller — the caller was a warehouse service that finished its job twenty seconds ago. You put the message back on the queue with a later ready time, and you make each wait longer than the last: 1s, 2s, 4s, 8s. That is exponential backoff, and most candidates stop there. Stopping there is how you take down your own provider.

Here is why. A provider blips and a thousand messages fail in the same second. With pure exponential backoff, all thousand wait exactly one second and all thousand retry in the same instant — a synchronised wall of traffic hitting a service that was already struggling. It fails again, and now all thousand wait two seconds together. You have built a metronome that hammers the provider harder each round. Jitter is the fix: randomise each delay inside a window so the retries spread out instead of arriving as a spike.

ONE MESSAGE, FOUR ATTEMPTS, THEN THE DEAD-LETTER QUEUE 0s 1s 2s 4s 8s attempt 1 fails attempt 2 wait 0.5–1s attempt 3 wait 1–2s attempt 4 wait 2–4s attempt 5 would wait 4–8s — but maxAttempts = 4 maxAttempts reached → DeadLetterQueue with the last reason, and the attempt trail orange band = the jitter window [0.5·base, base] WHY JITTER — 1000 MESSAGES THAT ALL FAILED AT THE SAME INSTANT ✗ no jitter all 1000 retry on the same tick — a wall of traffic the provider was already struggling. now it is down. ✓ jitter the same 1000 retries, spread across the window — the provider survives them Same number of retries. Same total delay budget. Completely different load on the thing you are retrying against.
The bottom half is the whole argument. Jitter does not reduce retries — it de-synchronises them. Say that sentence and you are past every candidate who only said “exponential backoff”.
RetryPolicy + the dispatcher loop
class RetryPolicy {
    private final long baseMillis, maxMillis;
    private final int maxAttempts;
    private final Random random;          // injected → tests are deterministic

    /** Equal jitter: half the delay is fixed, half is random. Never zero, never synchronised. */
    long delayFor(int attempt) {
        long base = Math.min(maxMillis, baseMillis * (1L << (attempt - 1)));  // 1s, 2s, 4s, 8s
        return base / 2 + (long) (random.nextDouble() * (base / 2.0));        // [0.5b, b]
    }
    boolean exhausted(int attempt) { return attempt >= maxAttempts; }
}

class Dispatcher {
    void runOnce(long now) {
        Notification n = queue.poll(now);            // respects priority AND readyAt
        if (n == null) return;

        DeliveryResult result = channels.get(n.channel()).send(n);

        if (result instanceof DeliveryResult.Success s) {
            n.markSent(s.providerId());
        } else if (result instanceof DeliveryResult.Permanent p) {
            deadLetters.add(n, p.reason());          // ZERO retries — it can never succeed
        } else if (result instanceof DeliveryResult.Retryable r) {
            if (policy.exhausted(n.attempt())) {
                deadLetters.add(n, "exhausted after " + n.attempt() + ": " + r.reason());
            } else {
                n.nextAttempt(now + policy.delayFor(n.attempt()));
                queue.offer(n);                      // back on the queue, later
            }
        }
    }
}

Retry only what you did not already do

Retrying is safe only if the previous attempt genuinely did nothing. A timeout is the awkward case: the provider may have accepted the message and lost the reply. That is why at-least-once delivery needs the next section — the retry is going to send twice sometimes, and the only defence is a key.

Idempotency — the sentence to say out loud

“At-least-once delivery plus idempotency.” Retries mean a message can go out twice. You cannot prevent that at the network level, so you make the duplicate harmless: every notification carries a stable key — userId + eventType + entityId — and the send path checks it before doing anything. Same order, same user, same event, same key, no second SMS.

publish(OrderShipped "ORD-91", "U-2") first publish publish(OrderShipped "ORD-91", "U-2") upstream retried — identical event key = "U-2:ORDER_SHIPPED:ORD-91" key = "U-2:ORDER_SHIPPED:ORD-91" same inputs → same key. no clock, no UUID. dedup.seen(key)? no → enqueue 💬 SMS sent dedup.seen(key)? yes → drop no channel called no second SMS ✗ key = UUID.randomUUID() or now() → every duplicate looks new, and dedup does nothing at all ✓ key derived from the event's own identity → the same fact always produces the same key
The whole mechanism is one lookup before the enqueue. What makes it work is that the key is derived, not generated — read the two lines at the bottom, because generating the key is the mistake that quietly disables the entire defence.

Where the dedup key lives, and for how long

In an interview: a Set with a TTL, or a Redis SETNX with an expiry in a real system. You do not keep keys forever — a few days is enough to cover any retry window. Mention the TTL unprompted; it is the difference between “I've read about idempotency” and “I've operated it”.

Step 7 · Enqueue, never send inline

publish() must not call channel.send(). If it does, a slow SMS provider is now inside your checkout request, and a five-second timeout at Twilio becomes a five-second checkout. publish() resolves, renders, dedupes and offers to a queue; a pool of workers drains it. That is Producer–Consumer with a very concrete payoff, and the retry machinery in Step 6 only works at all because there is a queue to put things back on. Size the pool with Thread pools & Executors, not with a thread per message.

OrderService NotificationSvc prefs+templates Queue Worker SmsChannel publish(OrderShipped) channelsFor(U-2, ORDER_SHIPPED) [SMS, PUSH] — email is off render(event, channel) × 2 a 160-char body + a push body dedup.seen(key)? no offer(n) × 2 returns in microseconds — checkout is not waiting …later, on a worker thread… poll() send(n) Retryable("503") offer(n, readyAt = now + jitter(2^1)) attempt 2 of 4 — nobody upstream ever hears about this
Read the green return arrow. publish() is already done before a single byte leaves for the SMS provider — and the retry at the bottom happens entirely below that line, invisible to the order service. Notation: Sequence diagrams.

Priority: an OTP must not queue behind a marketing blast

Marketing schedules 200,000 promos at 09:00. At 09:00:02 somebody tries to log in and needs an OTP. If both land in one FIFO queue, that OTP is delivered some time after lunch and the user is locked out. The rule is short: transactional traffic never shares a lane with promotional traffic.

  • Separate queues per class (transactional / transactional-bulk / promotional) with dedicated workers is the simplest answer and the one that cannot starve — each lane has guaranteed capacity.
  • A single priority queue ordered by (priority, readyAt) is fine at interview scale and is what the prototype does, but say the word starvation unprompted: an endless stream of high-priority work never lets the low lane run. The fix is a floor — reserve some workers for the low lane, or age priority upward.
  • Priority comes from the event type, not the caller. Priority.of(OTP) = HIGH lives in one table. Letting each publisher declare its own priority means everything is urgent within a month.
  • Retries keep their original priority but go to the back of their own lane — a failing OTP still outranks a fresh promo.

Rate limiting, batching, and digests

  • Per-user throttling is a safety net against your own bugs: a loop that fires 500 pushes at one person is a support incident and an app uninstall. Cap it — “at most N notifications per user per hour, per category” — and drop or fold the excess. Do not re-teach the algorithm in the round; say “a token bucket per user” and cross-reference Rate Limiter.
  • Provider-side limits are different and are a Retryable result, not a drop: a 429 from the provider means back off, not discard.
  • Batching / digests turn twelve notifications into “you have 12 new messages”. Implement it as a window: hold low-priority notifications for a user for N minutes, then flush one summary. It is a scheduler plus a per-user buffer, and it is the single best answer to “how do you stop being annoying?”
  • Digests must never batch transactional traffic. An OTP in a 15-minute digest is an OTP that has already expired.

Status tracking — because “did it arrive?” is a support question

PENDING SENT DELIVERED READ provider accepted delivery webhook user opened it RETRYING Retryable backoff elapsed FAILED attempts exhausted Permanent — no retry DEAD_LETTER async bounce webhook a human or a batch job reads the DLQ SENT means “the provider took it”. DELIVERED means “the phone got it”. Support tickets live entirely in the gap between those two.
Watch the dashed red arrow from SENT back down to FAILED. Email bounces arrive minutes after the provider said yes — which is why status is a stored field with a webhook updating it, not a return value. Notation: State diagrams.

The 60 minutes

MINUTE-BY-MINUTE — code before minute 26, retries before minute 45 0–5 5–11 11–18 18–26 26–41 41–51 51–58 58–60 clarify · scope board · “can we add WhatsApp?” the box diagram: event → service → prefs/templates → queue → channels preferences matrix and per-channel templates, both as rows Channel interface + the three-way DeliveryResult — the point of no return CODE: Notification, service.publish, queue, dispatcher, two channels RetryPolicy with backoff + jitter, maxAttempts, dead-letter queue idempotency key + a FakeChannel demo · then follow-ups
If minute 26 arrives and you have not started typing, cut the batching and digest conversation entirely. Retries and dedup are not optional; digests are.

The follow-ups

  • “Twilio is down. Fall back to a second SMS provider.” → wrap, don't branch. A FailoverChannel holding a primary and a secondary implements Channel and tries the second only on a Retryable result. That is Decorator / Chain of Responsibility, and it keeps the failover policy out of every channel's send(). An if (provider == twilio) inside SmsChannel is the answer they are hoping you do not give.
  • “Scheduled and delayed notifications.” → already free: the queue orders by readyAt, so a scheduled send is a notification enqueued with a future ready time. Same mechanism as a retry. Say that out loud — reusing one mechanism for two features is worth a point.
  • “Per-locale templates.” → the template key already has a locale component; the user's locale comes from the preference row. A new language is rows, not a release.
  • “How would you test this?” → a FakeChannel you construct with a script of outcomes: fail retryable twice, then succeed, or fail permanently. Inject it and inject a fixed-seed Random into the RetryPolicy, and the whole retry ladder becomes a deterministic unit test — the same trick as the injectable dice in Snake & Ladder. Assert on the dead-letter queue's contents. This is Dependency Injection & IoC earning its keep.
  • “How do you know it is working in production?” → delivery rate and dead-letter rate per channel, retry counts, and time from publish to delivered at p99. A DLQ that is growing is the alarm; a DLQ nobody looks at is a bug factory.
  • “A million notifications for one campaign.” → the design does not change shape, it changes deployment: the queue becomes a real broker, the workers scale horizontally, and the dedup set becomes Redis. Say that the classes survive and only the infrastructure moves — that is the answer they want.
  • “What if the user changes preferences while messages are queued?” → decide and say it: resolve preferences at publish time (fast, may be stale) or at send time (fresher, more lookups). For an unsubscribe, re-check at send time — sending after an opt-out is the one that gets a complaint.

How this round is lost

  • OrderService calls an email client. Everything else you say afterwards is decoration on a design that already failed the one question the problem asks.
  • A boolean DeliveryResult. You now cannot tell “try again in two seconds” from “this number will never work”, and every retry strategy built on top is guessing.
  • Retrying a permanent failure. Unbounded retries against an invalid number: a real incident, an easy one to avoid, and the interviewer is watching for it.
  • Retrying with no jitter. “Exponential backoff” said and then stopped. A thousand synchronised retries is a self-inflicted denial of service on your own provider.
  • No dedup key. At-least-once delivery with no idempotency means every retry storm double-sends, and users get the same OTP four times.
  • Sending inside the request. Checkout latency now depends on an SMS provider in another country.
  • A switch on a channel string. Every new channel edits that switch, and the Strategy seam you drew on the whiteboard is not actually there in the code.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

Press 📦 OrderShipped and watch it fan out: U-2 has SMS and push on for that event and email off, so two notifications leave, not three — and each box shows a body written for its channel, with a character count. Click the 📧 cell on the U-2 row to turn email on and fire the identical event again: a third box lights up with the full paragraph, and the publish call is byte-for-byte unchanged. Then turn on ⚠️ Flaky SMS and watch the retry timeline widen — 1s, 2s, 4s, 8s, each inside a jitter band — before the message lands in the dead-letter tray. Now try 💀 Invalid number: that one goes straight to the DLQ with no retries at all. Finish with ♻️ Duplicate publish (stopped at the dedup key), 🔥 Burst 12 with 🎲 Jitter off, and ⚡ Promo blast + OTP.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Publish one event and count the notifications

Press 📦 OrderShipped. The call line shows notifier.publish(new OrderShipped("ORD-91", "U-2")) — no channel named anywhere in it. Two channel boxes light up, not three, because U-2 has email off for this event. Read the two bodies and their counters: 36/160 on SMS, a title-length line on push. Same event, different template rows.

try 02

Change a preference, not a line of code

Click the 📧 cell on the U-2 row to turn email on, then press 📦 OrderShipped again. A third box lights up, holding the full paragraph with the tracking link — beside a 36-character SMS built from the same event. The publish call in the call line is byte-for-byte identical, and the explain line says 0 lines of code changed. That is the whole argument for keeping preferences as data.

try 03

Break the SMS provider and watch the ladder

Turn on ⚠️ Flaky SMS and fire 📦 OrderShipped. The SMS message drops into the queue as attempt 1/4 and the backoff timeline starts drawing ticks at widening gaps — each one inside a shaded jitter band, not on an exact second. After the fourth attempt it falls into the dead-letter tray with the reason attached. Note what did not happen: nothing failed upstream, and push and email delivered normally.

try 04

Now break it permanently, and compare

Press ↺ Reset, turn on 💀 Invalid number, and fire the same event. This time there is no timeline at all — the message goes straight to the dead-letter tray on attempt 1. Put the two runs side by side in your head: four attempts over eight seconds versus zero attempts. That difference is the entire reason DeliveryResult has three variants instead of two.

try 05

Turn jitter off and fire a burst

With ⚠️ Flaky SMS still on, switch 🎲 Jitter off and press 🔥 Burst 12. All twelve retries stack on the same tick — one tall column. Turn jitter back on, reset, and burst again: the same twelve retries spread across the window. Same number of retries, same delay budget, completely different load on the provider.

try 06

Duplicate it, then jump the queue

Press ♻️ Duplicate publish: the same event fires twice, the second copy is stopped at the dedup check with its key printed, the deduped counter ticks, and no extra message reaches a channel. Then press ⚡ Promo blast + OTP — six promos are enqueued first and the OTP last, and the queue strip still shows 🔔 OTP at the front. Transactional never waits behind promotional.

try 07

Build it from memory

Blank file, in this order: Notification with a key field → Channel interface returning a three-variant DeliveryResult → a FakeChannel you can tell to fail retryably or permanently → PreferenceStore and TemplateStore as maps → NotificationService.publish() that resolves, renders, dedupes and enqueues → a Dispatcher that polls, sends, and routes the result to ack / backoff / dead-letter. Run it with the fake channel failing twice then succeeding, and print the dead-letter queue at the end. If a permanent failure ever gets a second attempt, your dispatcher is treating two different things as one.

In practice

When to use it — and what trips people up

The shape you just learned

Strip the notifications away and this is an unreliable side effect, decoupled from the thing that caused it. A caller states a fact; a separate pipeline decides what to do about it and owns the consequences of failing. Once you see that shape you will find it everywhere, and the same four moves apply every time: a three-way result, bounded jittered retry, a dead-letter queue, and an idempotency key.

  • Webhook delivery — you POST to a customer's URL. It times out, or it returns 410 Gone. Identical problem, identical answer, and the dead-letter queue becomes a page in their dashboard.
  • Payment capture and refunds — at-least-once with an idempotency key is not optional there; it is the difference between one charge and three.
  • Search and cache invalidation — index writes fail, and retrying them forever against a document that no longer exists is the same permanent-failure bug.
  • Audit and analytics event shipping — the producer must not block on the sink, which is why the queue comes first, exactly as in Producer–Consumer.
  • Any outbound integration at all. The moment your code calls something you do not operate, you own retry classification, backoff and a place for the corpses.

The 25-second version to say out loud

“Publishers emit events, never notifications. The service resolves preferences and per-channel templates — both data — builds a notification with a stable idempotency key, and enqueues it. Workers send it through a Channel interface that returns success, retryable or permanent. Retryable gets exponential backoff with jitter up to a cap, then a dead-letter queue. Permanent goes to the dead-letter queue with zero retries. At-least-once delivery plus idempotency.” That is the whole design, and it fits in one breath.

Where this design stops working

  • When ordering matters. “Shipped” must not arrive after “delivered”. A priority queue plus jittered retries actively reorders things. If order matters you need a per-user or per-entity sequence and a channel that respects it — which is a real cost, so only pay it where it is genuinely required.
  • When the in-process queue dies with the process. Everything still pending is gone. At interview scale say so out loud and name the fix: persist the notification row before enqueuing, or use a durable broker. The classes do not change; the queue's implementation does.
  • When exactly-once is actually required. It does not exist end to end — you cannot stop a phone network from delivering an SMS twice. The honest answer is at-least-once plus idempotency at every consumer, and pretending otherwise is a worse answer than admitting it.
  • When the user is offline for a week. Retrying a push for seven days is pointless; the notification should expire. Add a expiresAt and drop instead of dead-lettering — an expired promo is not an incident.
  • When one user has millions of events. Per-user rate limiting and digesting stop being a nicety and become the primary design constraint, and the whole thing tilts toward the batching model instead of the per-event one.

If you only remember one thing

send() returning a boolean is the bug. Everything good in this design — the backoff ladder, the dead-letter queue, not hammering a provider with a number that will never work — depends on the channel being able to say “try again” and “never try again” as two different answers.

What it gives you

  • Publishers stay ignorant of channels, so adding WhatsApp is one class and some preference rows with zero edits to any service that emits events.
  • Preferences and templates are rows, so a new event type, a new language or a user changing their mind is configuration rather than a deploy.
  • A three-way DeliveryResult lets the dispatcher retry temporary failures and abandon permanent ones, which is what stops retry storms against addresses that can never work.
  • Exponential backoff with jitter spreads a thousand simultaneous failures across a window instead of firing them as one synchronised spike at an already-struggling provider.
  • A stable idempotency key makes at-least-once delivery safe, so retries and duplicate publishes cost nothing instead of double-sending.

Common mistakes

  • Asynchronous delivery means the caller gets no confirmation, so 'did it send?' becomes a separate status lookup and a support surface of its own.
  • The priority queue reorders messages, so notifications about the same entity can arrive out of sequence unless you add per-entity ordering on top.
  • An in-process queue loses everything pending if the process dies; durability means persisting notifications first, which adds a write to the hot path.
  • The dedup store is unbounded unless you give keys a TTL, and picking that TTL is a real trade between memory and how long a duplicate can still arrive.
  • A dead-letter queue nobody monitors is just a slower way to lose messages — the design only pays off if someone alerts on its growth rate.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.util.*;

enum ChannelType { EMAIL, SMS, PUSH, WHATSAPP }
enum EventType {
    ORDER_SHIPPED(Priority.NORMAL), OTP_REQUESTED(Priority.HIGH), PROMO(Priority.LOW);
    final Priority priority;
    EventType(Priority p) { this.priority = p; }
    boolean transactional() { return this != PROMO; }
}
enum Priority { HIGH, NORMAL, LOW }
enum Status { PENDING, RETRYING, SENT, FAILED, DEAD_LETTER }

/** A fact about the world. No address, no subject, no channel. */
record Event(EventType type, String userId, String entityId, Map<String, String> data) {}

class Notification {
    final String key;            // idempotency: userId + eventType + entityId
    final String userId, destination, body;
    final ChannelType channel;
    final Priority priority;
    int attempt = 1;
    long readyAt = 0;
    Status status = Status.PENDING;

    Notification(String key, String userId, ChannelType c, String dest, String body, Priority p) {
        this.key = key; this.userId = userId; this.channel = c;
        this.destination = dest; this.body = body; this.priority = p;
    }
}

/** Three outcomes, not two. This is the type the whole design turns on. */
sealed interface DeliveryResult {
    record Success(String providerId) implements DeliveryResult {}
    record Retryable(String reason) implements DeliveryResult {}
    record Permanent(String reason) implements DeliveryResult {}
}

interface Channel {
    ChannelType type();
    DeliveryResult send(Notification n);
}

/** A channel you can TELL to fail — this is how the retry ladder gets unit tested. */
class FakeChannel implements Channel {
    private final ChannelType type;
    private final Deque<DeliveryResult> script = new ArrayDeque<>();
    private final int maxBody;
    int calls = 0;

    FakeChannel(ChannelType type, int maxBody) { this.type = type; this.maxBody = maxBody; }
    FakeChannel scripted(DeliveryResult... rs) { script.addAll(List.of(rs)); return this; }

    public ChannelType type() { return type; }

    public DeliveryResult send(Notification n) {
        calls++;
        String body = n.body.length() > maxBody ? n.body.substring(0, maxBody - 3) + "..." : n.body;
        DeliveryResult r = script.isEmpty() ? new DeliveryResult.Success("p-" + calls) : script.poll();
        System.out.printf("    %-8s -> %-24s [%d chars] %s%n",
            type, r.getClass().getSimpleName(), body.length(), body);
        return r;
    }
}

/** Preferences are ROWS. user x eventType x channel -> enabled. */
class PreferenceStore {
    private final Set<String> enabled = new HashSet<>();
    private final Set<String> unsubscribed = new HashSet<>();

    void enable(String user, EventType e, ChannelType c) { enabled.add(user + "|" + e + "|" + c); }
    void unsubscribe(String user) { unsubscribed.add(user); }

    List<ChannelType> channelsFor(String user, EventType e, boolean quietHours) {
        if (unsubscribed.contains(user)) return List.of();          // one place, checked first
        if (quietHours && !e.transactional()) return List.of();      // OTP ignores quiet hours
        List<ChannelType> out = new ArrayList<>();
        for (ChannelType c : ChannelType.values())
            if (enabled.contains(user + "|" + e + "|" + c)) out.add(c);
        return out;
    }
}

/** Templates are ROWS too, keyed by (eventType, channel, locale). */
class TemplateStore {
    private final Map<String, String> rows = new HashMap<>();

    void put(EventType e, ChannelType c, String locale, String template) {
        rows.put(e + "|" + c + "|" + locale, template);
    }
    Optional<String> render(EventType e, ChannelType c, String locale, Map<String, String> data) {
        String t = rows.get(e + "|" + c + "|" + locale);
        if (t == null) t = rows.get(e + "|" + c + "|en");            // locale fallback
        if (t == null) return Optional.empty();                      // no template = no send
        for (var kv : data.entrySet()) t = t.replace("{" + kv.getKey() + "}", kv.getValue());
        return Optional.of(t);
    }
}

/** Exponential backoff with EQUAL JITTER: half fixed, half random. */
class RetryPolicy {
    private final long baseMillis, maxMillis;
    private final int maxAttempts;
    private final Random random;

    RetryPolicy(long base, long max, int maxAttempts, Random random) {
        this.baseMillis = base; this.maxMillis = max;
        this.maxAttempts = maxAttempts; this.random = random;
    }
    long delayFor(int attempt) {
        long base = Math.min(maxMillis, baseMillis * (1L << (attempt - 1)));   // 1s 2s 4s 8s
        return base / 2 + (long) (random.nextDouble() * (base / 2.0));         // de-synchronise
    }
    boolean exhausted(int attempt) { return attempt >= maxAttempts; }
}

class DeadLetterQueue {
    final List<String> entries = new ArrayList<>();
    void add(Notification n, String reason) {
        n.status = Status.DEAD_LETTER;
        entries.add(n.channel + " " + n.key + " after " + n.attempt + " attempt(s): " + reason);
    }
}

/** Ordered by (priority, readyAt) — an OTP never waits behind a promo blast. */
class NotificationQueue {
    private final PriorityQueue<Notification> q = new PriorityQueue<>(
        Comparator.<Notification, Integer>comparing(n -> n.priority.ordinal())
                  .thenComparingLong(n -> n.readyAt));

    void offer(Notification n) { q.offer(n); }
    boolean isEmpty() { return q.isEmpty(); }
    long nextReadyAt() { return q.isEmpty() ? -1 : q.peek().readyAt; }
    Notification poll(long now) {
        Notification head = q.peek();
        return (head != null && head.readyAt <= now) ? q.poll() : null;
    }
}

class NotificationService {
    private final PreferenceStore prefs; private final TemplateStore templates;
    private final NotificationQueue queue; private final Set<String> dedup = new HashSet<>();
    int dedupedCount = 0;

    NotificationService(PreferenceStore p, TemplateStore t, NotificationQueue q) {
        this.prefs = p; this.templates = t; this.queue = q;
    }

    /** Resolve -> render -> dedup -> ENQUEUE. It never calls send(). */
    int publish(Event event, boolean quietHours, long now) {
        int made = 0;
        for (ChannelType c : prefs.channelsFor(event.userId(), event.type(), quietHours)) {
            Optional<String> body = templates.render(event.type(), c, "en", event.data());
            if (body.isEmpty()) continue;
            String key = event.userId() + ":" + event.type() + ":" + event.entityId() + ":" + c;
            if (!dedup.add(key)) { dedupedCount++; continue; }        // at-least-once needs this
            Notification n = new Notification(key, event.userId(), c,
                event.userId() + "@dest", body.get(), event.type().priority);
            n.readyAt = now;
            queue.offer(n);
            made++;
        }
        return made;
    }
}

class Dispatcher {
    private final NotificationQueue queue; private final Map<ChannelType, Channel> channels;
    private final RetryPolicy policy; private final DeadLetterQueue dlq;
    int sent = 0, retried = 0;

    Dispatcher(NotificationQueue q, List<Channel> cs, RetryPolicy p, DeadLetterQueue d) {
        this.queue = q; this.policy = p; this.dlq = d;
        this.channels = new EnumMap<>(ChannelType.class);
        for (Channel c : cs) channels.put(c.type(), c);
    }

    /** Simulated clock so the demo is deterministic — a real worker would block on the queue. */
    void drain(long startAt) {
        long now = startAt;
        while (!queue.isEmpty()) {
            Notification n = queue.poll(now);
            if (n == null) { now = queue.nextReadyAt(); continue; }   // jump to the next ready time
            System.out.printf("  t=%5dms  %s attempt %d%n", now, n.channel, n.attempt);
            DeliveryResult r = channels.get(n.channel).send(n);

            if (r instanceof DeliveryResult.Success s) {
                n.status = Status.SENT; sent++;
            } else if (r instanceof DeliveryResult.Permanent p) {
                dlq.add(n, p.reason());                                // ZERO retries
                System.out.println("      -> dead-letter immediately (" + p.reason() + ")");
            } else if (r instanceof DeliveryResult.Retryable rr) {
                if (policy.exhausted(n.attempt)) {
                    dlq.add(n, "exhausted: " + rr.reason());
                    System.out.println("      -> dead-letter after " + n.attempt + " attempts");
                } else {
                    long delay = policy.delayFor(n.attempt);
                    n.attempt++; n.status = Status.RETRYING; n.readyAt = now + delay; retried++;
                    queue.offer(n);
                    System.out.println("      -> retry in " + delay + "ms (jittered)");
                }
            }
        }
    }
}

public class Main {
    public static void main(String[] args) {
        PreferenceStore prefs = new PreferenceStore();
        prefs.enable("U-2", EventType.ORDER_SHIPPED, ChannelType.SMS);
        prefs.enable("U-2", EventType.ORDER_SHIPPED, ChannelType.PUSH);   // email deliberately OFF
        prefs.enable("U-2", EventType.OTP_REQUESTED, ChannelType.SMS);

        TemplateStore templates = new TemplateStore();
        templates.put(EventType.ORDER_SHIPPED, ChannelType.SMS, "en", "{orderId} shipped. Track: sub.rt/{orderId}");
        templates.put(EventType.ORDER_SHIPPED, ChannelType.PUSH, "en", "On its way - order {orderId} has shipped");
        templates.put(EventType.OTP_REQUESTED, ChannelType.SMS, "en", "Your code is {code}. Valid 5 minutes.");

        NotificationQueue queue = new NotificationQueue();
        NotificationService notifier = new NotificationService(prefs, templates, queue);
        DeadLetterQueue dlq = new DeadLetterQueue();

        // SMS fails twice with a retryable error, then succeeds. Deterministic, on purpose.
        FakeChannel sms = new FakeChannel(ChannelType.SMS, 160).scripted(
            new DeliveryResult.Retryable("503 from provider"),
            new DeliveryResult.Retryable("timeout"),
            new DeliveryResult.Success("sm-77"));
        FakeChannel push = new FakeChannel(ChannelType.PUSH, 120);

        Dispatcher dispatcher = new Dispatcher(queue, List.of(sms, push),
            new RetryPolicy(1000, 8000, 4, new Random(7)), dlq);

        System.out.println("-- publish OrderShipped for U-2 --");
        int made = notifier.publish(new Event(EventType.ORDER_SHIPPED, "U-2", "ORD-91",
            Map.of("orderId", "ORD-91")), false, 0);
        System.out.println("  " + made + " notifications (email is off for this user+event)");
        dispatcher.drain(0);

        System.out.println("-- the same event again (upstream retried) --");
        System.out.println("  " + notifier.publish(new Event(EventType.ORDER_SHIPPED, "U-2", "ORD-91",
            Map.of("orderId", "ORD-91")), false, 0) + " notifications, deduped "
            + notifier.dedupedCount);

        System.out.println("-- OTP to a number the provider rejects --");
        FakeChannel badSms = new FakeChannel(ChannelType.SMS, 160)
            .scripted(new DeliveryResult.Permanent("invalid number"));
        NotificationQueue q2 = new NotificationQueue();
        NotificationService n2 = new NotificationService(prefs, templates, q2);
        n2.publish(new Event(EventType.OTP_REQUESTED, "U-2", "LOGIN-5",
            Map.of("code", "402913")), true, 0);          // quiet hours: OTP still goes
        new Dispatcher(q2, List.of(badSms), new RetryPolicy(1000, 8000, 4, new Random(7)), dlq)
            .drain(0);

        System.out.println("-- dead-letter queue --");
        dlq.entries.forEach(e -> System.out.println("  " + e));
    }
}

/* expected output (delays vary only with the Random seed)

-- publish OrderShipped for U-2 --
  2 notifications (email is off for this user+event)
  t=    0ms  SMS attempt 1
    SMS      -> Retryable                [36 chars] ORD-91 shipped. Track: sub.rt/ORD-91
      -> retry in 738ms (jittered)
  t=    0ms  PUSH attempt 1
    PUSH     -> Success                  [40 chars] On its way - order ORD-91 has shipped
  t=  738ms  SMS attempt 2
    SMS      -> Retryable                [36 chars] ORD-91 shipped. Track: sub.rt/ORD-91
      -> retry in 1543ms (jittered)
  t= 2281ms  SMS attempt 3
    SMS      -> Success                  [36 chars] ORD-91 shipped. Track: sub.rt/ORD-91
-- the same event again (upstream retried) --
  0 notifications, deduped 2
-- OTP to a number the provider rejects --
  t=    0ms  SMS attempt 1
    SMS      -> Permanent                [33 chars] Your code is 402913. Valid 5 minutes.
      -> dead-letter immediately (invalid number)
-- dead-letter queue --
  SMS U-2:OTP_REQUESTED:LOGIN-5:SMS after 1 attempt(s): invalid number
*/

References & further reading

7 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 08

The order service needs to tell a customer their parcel shipped. What should it call?

question 02 / 08

Why must Channel.send() return three outcomes rather than a boolean?

question 03 / 08

A provider blips and 1,000 sends fail in the same second. You retry with exponential backoff but no jitter. What happens?

question 04 / 08

What is the right idempotency key for a shipped-order notification?

question 05 / 08

The interviewer asks you to add WhatsApp. In a good design, what changes?

question 06 / 08

Should publish() call channel.send() directly?

question 07 / 08

Marketing schedules 200,000 promos for 09:00. At 09:00:02 a user requests a login OTP. What stops the OTP arriving after lunch?

question 08 / 08

How would you unit-test that a message dead-letters after exactly four attempts?

0/8 answered