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.
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
- Does the publisher stay ignorant? If
OrderServiceever touches an email address, a phone number or the string"SMS", you have already lost the main point. - Is
DeliveryResultmore than a boolean? Success, retryable failure and permanent failure are three different things, and treating the last two the same is a production incident. - 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.
- Is delivery idempotent? Retries mean at-least-once. Without a stable key and a dedup check, every retry storm double-sends.
- 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.
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 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.
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
ifin 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).
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.
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.
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.
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.
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.
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) = HIGHlives 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
Retryableresult, not a drop: a429from 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
The 60 minutes
The follow-ups
- “Twilio is down. Fall back to a second SMS provider.” → wrap, don't branch. A
FailoverChannelholding a primary and a secondary implementsChanneland tries the second only on aRetryableresult. That is Decorator / Chain of Responsibility, and it keeps the failover policy out of every channel'ssend(). Anif (provider == twilio)insideSmsChannelis 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
FakeChannelyou construct with a script of outcomes: fail retryable twice, then succeed, or fail permanently. Inject it and inject a fixed-seedRandominto theRetryPolicy, 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
OrderServicecalls 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
switchon 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.
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.
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.
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.
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.
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.
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.
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
expiresAtand 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- Articleaws.amazon.com
Exponential Backoff and Jitter — AWS Architecture Blog
The canonical write-up, with the simulation that shows full jitter beating plain exponential backoff. Read this one first — it is the source of the money figure in this lesson.
- Articleaws.amazon.com
Timeouts, retries and backoff with jitter — Amazon Builders' Library
Longer and more operational: retry budgets, why every layer retrying multiplies load, and when not to retry at all.
- Docsenterpriseintegrationpatterns.com
Dead Letter Channel — Enterprise Integration Patterns
The pattern by its formal name, alongside Guaranteed Delivery and Idempotent Receiver — the vocabulary that makes this design sound routine rather than invented on the spot.
- Docsdocs.aws.amazon.com
Amazon SQS dead-letter queues
How a production queue actually implements maxReceiveCount and redrive. Useful for answering 'and how would you run this?' concretely.
- Docsdocs.stripe.com
Stripe: Idempotent Requests
Idempotency keys as a public API contract, including the TTL. The clearest short example of the exact mechanism this lesson uses.
- Docshelp.twilio.com
Twilio SMS character limits and segments
Why 160 characters is a real constraint with a real per-segment cost — the concrete fact behind 'templates are per channel'.
- Book
Designing Data-Intensive Applications — Kleppmann
Chapter 11 on message brokers, at-least-once delivery and why exactly-once is a marketing term. The theory under everything in this lesson.
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