Skip to content

Low Level DesignWorked problems

Design a notification service

Design a notification service step by step for a low level design interview. Grow from a hard-coded email sender to email, SMS and push behind one channel interface, templates written once per channel with checked placeholders, a delivery queue with retries and exponential backoff, and a shared service where duplicate requests send once and two workers never send the same message, with code in Java, Python, C#, Go and TypeScript.

Mid-level26 min core · 45 min full depth

“Design a notification service: other parts of the app ask it to tell a user something, and it sends email, SMS or push.” Then: “the SMS provider is down for ten minutes. What happens to the messages?”

Every product has a notification service. The order service says “order 42 shipped”. The security service says “new sign-in from Lisbon”. Something has to turn that into an email, a text message (SMS) and a phone notification (push), each worded for its channel, and sent to the right address.

That part is a clean exercise in interfaces. What makes it a real design question is failure. Providers (the outside companies that actually deliver email, SMS and push) time out, limit how fast you can send, and go down. Callers retry. And a user who gets the same text message three times notices.

If you only have a minute, this is the design. The rest of the page explains how we get here.

  1. Every channel is behind one Channel interface. Each implementation wraps one provider, so adding WhatsApp is a new class, not an edit.
  2. A template is written once per channel, and every placeholder must be filled, or nothing is sent on any channel.
  3. Asking for a notification only queues it. Workers send later. A temporary failure is retried with growing waits, up to a limit. A permanent failure isn’t retried.
  4. A caller’s idempotency key makes a repeated request send once. It’s a name the caller gives each notification, so a retry can be recognised.
  5. Workers claim a delivery under a lock, and send outside it, so no message is sent twice by two workers, and a slow provider stalls nobody.

We’ll build this in four stages, then share the service between many callers and workers.

Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.

Interviewerminute 0

Design a notification service.

What they are testing

Whether you find the scope fast: which channels, who calls it, and what “delivered” means. The red flag is designing an email server, or spending the time on the user’s settings page.

Here are the questions that matter:

  • Which channels? Email, SMS and push, with more later.
  • Who writes the text? Templates that the service owns.
  • Must the caller wait for the send? No: queue it and return.
  • What if a provider fails? Retry temporary failures, then give up.
  • Can a caller ask twice by mistake? Yes, so repeats must send only once.

These answers give us one promise that matters more than the others: a message is never sent twice, and never sent with a blank placeholder. It must hold when callers retry and workers run in parallel. Two smaller promises come with it. A slow or failing provider never slows the caller. And adding a channel doesn’t change the service.

We leave out user preferences at first, quiet hours, and tracking opens and clicks.

Go deeperThree more questions, and what each changes1 min
Question We assume If the answer were different
Can users opt out? Out of scope at first A preference check per user and category before queuing
Quiet hours? No Deliveries get a not-before time in the user’s time zone
Do we track opens and clicks? No Tracking links, and a webhook from the provider per message

We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram). Time is passed in as a number of milliseconds, so the tests control it.

Interviewerminute 3

Start simple: when an order ships, email the customer.

What they are testing

Whether you can get something working quickly, and then name what breaks first: a second channel, consistent wording, or a provider failing.

Start with the smallest thing that works. The NotificationService keeps each user’s email address, and calls an EmailSender.

Stage 1: one channel

NotificationService

-emails: Map<String, String>

+register(userId, address)

+notifyUser(userId, subject, body)

EmailSender

+send(to, subject, body)

NotificationService

-emails: Map<String, String>

+register(userId, address)

+notifyUser(userId, subject, body)

EmailSender

+send(to, subject, body)

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
NotificationService.javanotification_service.pyNotificationService.csnotification_service.gonotification-service.ts
class EmailSender {
final List<String> sent = new ArrayList<>();
void send(String to, String subject, String body) { sent.add(to + ": " + subject); }
}
class NotificationService {
private final Map<String, String> emails = new HashMap<>();
private final EmailSender email;
NotificationService(EmailSender email) { this.email = email; }
void register(String userId, String address) { emails.put(userId, address); }
// notifyUser has three flaws.
// flaw 1: email is the only channel, and adding SMS means editing this class.
// flaw 2: every caller writes its own text, so the same alert reads differently everywhere.
// flaw 3: if the email server is down, the notification is simply lost.
void notifyUser(String userId, String subject, String body) {
email.send(emails.get(userId), subject, body);
}
}

It works, but it has three problems, and we leave them in on purpose. Email is the only channel, and adding SMS means editing the service. Every caller writes its own subject and body, so the same event reads differently depending on who sent it. And if the email server is down, the call fails, and the notification is lost.

Interviewerminute 5

Now add SMS and push. Next quarter, WhatsApp.

What they are testing

Whether you put the channels behind one interface and let the service pick by type, so a new channel is a new class. A switch on channel type inside the service is the red flag.

Every channel implements Channel: it says its type, and sends a Message. A message carries its channel, the address, a subject and a body. SMS ignores the subject.

Each implementation turns one provider’s API into that one method. That’s the Adapter pattern: an SmsChannel turns a message into a call to the SMS gateway’s API, and nothing else in the service knows that gateway exists. The service sends the same way, whatever the channel, which is the Strategy pattern.

A User has an address per channel: an email address, a phone number, a device token (the id a phone gives for push messages). The service sends on each requested channel the user has an address for, and skips the rest.

Stage 2: channels behind one interface

NotificationService

-channels: Map<ChannelType, Channel>

-users: Map<String, User>

+addUser(user)

+notifyUser(userId, subject, body, via) : List<Message>

«interface»

Channel

+type() : ChannelType

+send(message)

EmailChannel

SmsChannel

PushChannel

«value»

Message

+type: ChannelType

+to

+subject

+body

«value»

User

+id

+addresses: Map<ChannelType, String>

«enum»

ChannelType

EMAIL SMS PUSH

NotificationService

-channels: Map<ChannelType, Channel>

-users: Map<String, User>

+addUser(user)

+notifyUser(userId, subject, body, via) : List<Message>

«interface»

Channel

+type() : ChannelType

+send(message)

EmailChannel

SmsChannel

PushChannel

«value»

Message

+type: ChannelType

+to

+subject

+body

«value»

User

+id

+addresses: Map<ChannelType, String>

«enum»

ChannelType

EMAIL SMS PUSH

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
NotificationService.javanotification_service.pyNotificationService.csnotification_service.gonotification-service.ts
// The ways we can reach a user. Adding one is a new value here and a new Channel class.
enum ChannelType { EMAIL, SMS, PUSH }
// One message on one channel: where it goes and what it says. SMS ignores the subject.
record Message(ChannelType type, String to, String subject, String body) {}
// Every channel looks the same to the service. Each implementation adapts one provider's API,
// such as an email service, an SMS gateway or a push service, to this one method.
interface Channel {
ChannelType type();
void send(Message m);
}
// A user's address on each channel they've given us: an email address, a phone number, a device
// token. No address on a channel means nothing is sent there.
record User(String id, Map<ChannelType, String> addresses) {}
class NotificationService {
private final Map<ChannelType, Channel> channels = new EnumMap<>(ChannelType.class);
private final Map<String, User> users = new HashMap<>();
NotificationService(List<Channel> channels) {
for (Channel c : channels) this.channels.put(c.type(), c);
}
void addUser(User u) { users.put(u.id(), u); }
// Sends on each requested channel the user has an address for, and returns what was sent.
List<Message> notifyUser(String userId, String subject, String body, List<ChannelType> via) {
User user = users.get(userId);
List<Message> sent = new ArrayList<>();
if (user == null) return sent;
for (ChannelType type : via) {
String to = user.addresses().get(type);
Channel channel = channels.get(type);
if (to == null || channel == null) continue;
Message m = new Message(type, to, subject, body);
channel.send(m);
sent.add(m);
}
return sent;
}
}

The code shows the interface and the service. The three channel classes are thin wrappers around each provider’s library, and the tests use a fake channel that records what it was given.

Go deeperWhat a channel adapter actually does2 min

Each provider has its own API, and they all look different. An email provider wants a sender, a recipient, a subject and an HTML body, and answers with a message id. An SMS gateway wants a phone number in international format and plain text, usually limited to 160 characters per segment, and answers with a status code. A push service wants a device token and a small JSON payload, and may answer “this token is no longer valid”.

An adapter’s job is to hide all of that behind send(message):

  1. Translate the message into the provider’s request: format the phone number, wrap the email body in HTML, build the push payload.
  2. Call the provider, with a time limit, so a hung connection becomes a timeout instead of a stuck worker.
  3. Translate the answer into our three outcomes. A timeout or “too many requests” becomes RETRY. “Invalid number” or “token not registered” becomes FAILED. Success becomes SENT.

Step 3 is the important one. Only the adapter knows what the provider’s error codes mean, so only the adapter can decide whether a failure is temporary. The rest of the service just reads the outcome. Switching from one SMS provider to another means writing one new adapter, and nothing else changes.

Interviewerminute 9

Product wants every “order shipped” message worded the same, and the SMS must be short. Who writes the text?

What they are testing

Whether the wording moves out of the callers into templates the service owns, one per channel, and whether you think about a template with a value missing. “Hi null” is the red flag.

A Template is one kind of notification, such as shipped, with a subject and one body per channel. An email can be friendly and long. An SMS is one line. A push notification fits on a lock screen. A channel with no body in the template isn’t used for that notification: a receipt goes by email only, even to a user who has a phone number.

Callers now send a template id and values, such as {name: "Ana", order: "42"}. Rendering replaces every {{name}} in the text with its value. A placeholder with no value is an error.

Here is the shipped template the tests use, rendered for Ana and order 42:

Channel Template body What Ana receives
Email Hi {{name}}, order {{order}} is on its way. Hi Ana, order 42 is on its way.
SMS Order {{order}} shipped Order 42 shipped
Push Order {{order}} shipped Nothing: Ana has no push token

The email’s subject, Order {{order}} shipped, is rendered the same way. One request from the order service became two messages, each worded for its channel, and nobody outside the service wrote a word of it.

And the service renders every channel’s message before sending any. So a missing value stops the whole notification, instead of sending the email and then failing on the SMS. Half a notification is worse than none, because the caller can’t tell which half went out.

Stage 3: one notification, three messages

no device token, skipped

notify ana, shipped, name Ana, order 42

Template: shipped

Email: Hi Ana, order 42 is on its way.

SMS: Order 42 shipped

Push: Order 42 shipped

to ana@example.com

to +15550100

not sent

no device token, skipped

notify ana, shipped, name Ana, order 42

Template: shipped

Email: Hi Ana, order 42 is on its way.

SMS: Order 42 shipped

Push: Order 42 shipped

to ana@example.com

to +15550100

not sent

Scroll or pinch to zoom, drag to pan
Stage 3: templates

NotificationService

+notifyUser(userId, templateId, values) : List<Message>

-render(userId, templateId, values) : List<Message>

Templates

+add(template)

+get(id) : Template

+render(text, values) : String

«value»

Template

+id

+subject

+bodies: Map<ChannelType, String>

MissingValueException

«interface»

Channel

+type() : ChannelType

+send(message)

«value»

User

+addresses: Map<ChannelType, String>

NotificationService

+notifyUser(userId, templateId, values) : List<Message>

-render(userId, templateId, values) : List<Message>

Templates

+add(template)

+get(id) : Template

+render(text, values) : String

«value»

Template

+id

+subject

+bodies: Map<ChannelType, String>

MissingValueException

«interface»

Channel

+type() : ChannelType

+send(message)

«value»

User

+addresses: Map<ChannelType, String>

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
NotificationService.javanotification_service.pyNotificationService.csnotification_service.gonotification-service.ts
// One kind of notification, such as "order shipped", written once per channel. An SMS is short;
// an email can say more. A channel with no text here isn't used for this notification.
record Template(String id, String subject, Map<ChannelType, String> bodies) {}
// Thrown when a template names a value the caller didn't give. A bug in the caller, so it is loud.
class MissingValueException extends RuntimeException {
MissingValueException(String name) { super("no value for {{" + name + "}}"); }
}
// Every template, by id, and the code that fills them in.
class Templates {
private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{(\\w+)}}");
private final Map<String, Template> byId = new HashMap<>();
void add(Template t) { byId.put(t.id(), t); }
Template get(String id) { return byId.get(id); }
// Fills in every {{name}}. A placeholder with no value throws: a customer should never get
// "Hi {{name}}, your order {{orderId}} has shipped".
static String render(String text, Map<String, String> values) {
Matcher m = PLACEHOLDER.matcher(text);
StringBuilder out = new StringBuilder();
while (m.find()) {
String value = values.get(m.group(1));
if (value == null) throw new MissingValueException(m.group(1));
m.appendReplacement(out, Matcher.quoteReplacement(value));
}
return m.appendTail(out).toString();
}
}
class NotificationService {
private final Map<ChannelType, Channel> channels = new EnumMap<>(ChannelType.class);
private final Map<String, User> users = new HashMap<>();
private final Templates templates;
NotificationService(List<Channel> channels, Templates templates) {
for (Channel c : channels) this.channels.put(c.type(), c);
this.templates = templates;
}
void addUser(User u) { users.put(u.id(), u); }
// Renders every message before sending any, so a missing value stops the whole notification
// instead of sending the email and then failing on the SMS.
List<Message> notifyUser(String userId, String templateId, Map<String, String> values) {
List<Message> messages = render(userId, templateId, values);
for (Message m : messages) channels.get(m.type()).send(m);
return messages;
}
List<Message> render(String userId, String templateId, Map<String, String> values) {
User user = users.get(userId);
Template t = templates.get(templateId);
List<Message> out = new ArrayList<>();
if (user == null || t == null) return out;
for (ChannelType type : ChannelType.values()) { // a fixed order, so results are predictable
String body = t.bodies().get(type);
String to = user.addresses().get(type);
if (body == null || to == null || !channels.containsKey(type)) continue;
out.add(new Message(type, to, Templates.render(t.subject(), values), Templates.render(body, values)));
}
return out;
}
}
Interviewerminute 14

The SMS provider times out for ten minutes. What happens to the messages, and to the services calling you?

What they are testing

Whether you separate accepting a notification from sending it, tell temporary failures from permanent ones, and retry with growing waits and a limit. Retrying straight away in a loop is the red flag: it hammers a provider that’s already struggling.

The service now has two halves. notifyUser renders the messages, queues one Delivery per message, and returns. Callers never wait on a provider. process, run by a worker in a loop, sends every delivery that’s due.

A channel’s send now returns an Outcome, because failures are not all alike:

  • SENT: done.
  • RETRY: a temporary problem, such as a timeout, a rate limit, or a “service unavailable” error. Trying later may work.
  • FAILED: permanent, such as an invalid phone number or an app that was uninstalled. Trying again will fail again, and sending to a bad number again and again can get the sender marked as spam.

A retried delivery waits before its next attempt, and the wait doubles each time: one second, then two, then four. This is called exponential backoff. The RetryPolicy holds the limit and the first wait. Doubling gives a struggling provider room to recover, while retrying at once from every queue would keep it down. After the last attempt, the delivery is marked failed.

Stage 4: a delivery's life

notifyUser queues it

send says SENT

RETRY, wait 1s, 2s, 4s

FAILED, or the last attempt

Pending

Sent

Failed

notifyUser queues it

send says SENT

RETRY, wait 1s, 2s, 4s

FAILED, or the last attempt

Pending

Sent

Failed

Scroll or pinch to zoom, drag to pan
  • Color key
  • State
  • Fails or stops
Stage 4: deliveries and retries

NotificationService

-pending: List<Delivery>

+notifyUser(userId, templateId, values, now) : List<Delivery>

+process(now) : int

Delivery

+id

+message: Message

+status: Status

+attempts: int

+nextAttemptAt: long

«value»

RetryPolicy

+maxAttempts

+baseDelay

+delayAfter(attempts) : long

«interface»

Channel

+type() : ChannelType

+send(message) : Outcome

«enum»

Outcome

SENT RETRY FAILED

«enum»

Status

PENDING SENT FAILED

NotificationService

-pending: List<Delivery>

+notifyUser(userId, templateId, values, now) : List<Delivery>

+process(now) : int

Delivery

+id

+message: Message

+status: Status

+attempts: int

+nextAttemptAt: long

«value»

RetryPolicy

+maxAttempts

+baseDelay

+delayAfter(attempts) : long

«interface»

Channel

+type() : ChannelType

+send(message) : Outcome

«enum»

Outcome

SENT RETRY FAILED

«enum»

Status

PENDING SENT FAILED

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
NotificationService.javanotification_service.pyNotificationService.csnotification_service.gonotification-service.ts
// What a provider said. RETRY is a temporary problem, such as a timeout or a rate limit, worth
// trying again. FAILED is permanent, such as an invalid phone number, and retrying won't help.
enum Outcome { SENT, RETRY, FAILED }
// Every channel looks the same to the service. Each implementation adapts one provider's API,
// such as an email service, an SMS gateway or a push service, to this one method.
interface Channel {
ChannelType type();
Outcome send(Message m);
}
enum Status { PENDING, SENT, FAILED }
// One message's journey: queued, tried one or more times, then sent or given up on.
class Delivery {
final String id;
final Message message;
Status status = Status.PENDING;
int attempts; // tries so far, for the retry limit and the back-off delay
long nextAttemptAt; // not sent again before this time
Delivery(String id, Message message, long now) { this.id = id; this.message = message; this.nextAttemptAt = now; }
}
// Waits twice as long after each failed attempt: 1s, 2s, 4s. A provider that's struggling gets
// room to recover instead of a flood of retries from every sender at once.
record RetryPolicy(int maxAttempts, long baseDelay) {
long delayAfter(int attempts) { return baseDelay << (attempts - 1); }
}
class NotificationService {
private final Map<ChannelType, Channel> channels = new EnumMap<>(ChannelType.class);
private final Map<String, User> users = new HashMap<>();
private final Templates templates;
private final RetryPolicy retry;
private final List<Delivery> pending = new ArrayList<>();
private int nextId = 1;
NotificationService(List<Channel> channels, Templates templates, RetryPolicy retry) {
for (Channel c : channels) this.channels.put(c.type(), c);
this.templates = templates;
this.retry = retry;
}
void addUser(User u) { users.put(u.id(), u); }
// notifyUser only queues. The caller, say the order service, returns at once instead of
// waiting on an email provider, and a failed send can wait and retry without holding anyone up.
List<Delivery> notifyUser(String userId, String templateId, Map<String, String> values, long now) {
List<Delivery> queued = new ArrayList<>();
for (Message m : render(userId, templateId, values)) queued.add(new Delivery("D" + nextId++, m, now));
pending.addAll(queued);
return queued;
}
// process tries every delivery that is due, once. A worker calls it in a loop.
int process(long now) {
List<Delivery> due = pending.stream().filter(d -> d.nextAttemptAt <= now).toList();
for (Delivery d : due) attempt(d, now);
pending.removeIf(d -> d.status != Status.PENDING);
return due.size();
}
private void attempt(Delivery d, long now) {
d.attempts++;
Outcome outcome = channels.get(d.message.type()).send(d.message);
if (outcome == Outcome.SENT) d.status = Status.SENT;
else if (outcome == Outcome.FAILED || d.attempts >= retry.maxAttempts()) d.status = Status.FAILED;
else d.nextAttemptAt = now + retry.delayAfter(d.attempts);
}
private List<Message> render(String userId, String templateId, Map<String, String> values) {
User user = users.get(userId);
Template t = templates.get(templateId);
List<Message> out = new ArrayList<>();
if (user == null || t == null) return out;
for (ChannelType type : ChannelType.values()) { // a fixed order, so results are predictable
String body = t.bodies().get(type);
String to = user.addresses().get(type);
if (body == null || to == null || !channels.containsKey(type)) continue;
out.add(new Message(type, to, Templates.render(t.subject(), values), Templates.render(body, values)));
}
return out;
}
}
Go deeperA ten-minute SMS outage, minute by minute2 min

Let’s follow one SMS through an outage, with a limit of three attempts and a first wait of one second.

  1. 12:00:00. The order service calls notifyUser. The service renders the email, SMS and push messages, queues three deliveries, and returns. The order service is done in milliseconds.
  2. 12:00:00. A worker sends the email (SENT) and the push (SENT). The SMS provider times out: RETRY. The SMS delivery’s next attempt is set to 12:00:01.
  3. 12:00:01. The worker tries again. Still down: RETRY. Next attempt: 12:00:03, two seconds later.
  4. 12:00:03. Third attempt, still down. That was the last one, so the delivery is marked failed.

Notice what didn’t happen. The order service never waited. The email and push weren’t held up by the SMS. And the SMS provider got three tries in three seconds, not hundreds.

But a ten-minute outage outlasts three attempts in three seconds. That’s a choice of numbers, not of design: a policy with a first wait of 30 seconds and eight attempts keeps trying for over an hour (30 s, 1 min, 2, 4, 8, 16, 32 minutes). For an order update, that’s right. For a sign-in code that expires in five minutes, retrying for an hour is pointless, so different templates can use different policies. That’s why RetryPolicy is its own object.

Go deeperHow to wait between retries1 min
Fixed wait Exponential backoff (our choice) Backoff with jitter
A brief blip Recovers quickly Recovers quickly Recovers quickly
A ten-minute outage Hammers the provider Backs off, then tries rarely Backs off, then tries rarely
Many messages failing together Retry together Still retry together Spread out, no stampede
Simplicity Simplest One doubling One random number more

In production, add jitter: a random part of each wait, so ten thousand messages that failed in the same second don’t all retry in the same second. It’s left out here so the tests can predict the times.

Go deeperTemporary or permanent: sorting real errors1 min

Getting this sorting right matters more than the retry numbers. Some common cases:

What the provider says Outcome Why
Timeout, or no answer at all RETRY The provider may be slow, or the network blinked
“Too many requests” (HTTP 429) RETRY We’re sending too fast; waiting is the fix
“Service unavailable” (HTTP 503) RETRY The provider is down or restarting
“Invalid phone number” or “bad address” FAILED The same address will fail every time
“Token not registered” (app uninstalled) FAILED The device will never accept it again
“Unauthorised” (our credentials are bad) FAILED, and alert Retrying won’t help until a person fixes it

A wrong sort costs in both directions. Retrying a permanent failure wastes attempts, and can get us marked as spam. Giving up on a temporary failure loses a message that would have gone through a minute later. When unsure, retry: the limit stops it from going on forever.

This is enough for a mid-level answer. Channels behind one interface, templates per channel, a queue, and retries with a limit. The rest of the page is what makes it a senior answer.

Here is everything together, including the pieces for sharing that we add next. Callers use the service. The service renders templates, queues deliveries, and sends them through channels, each of which wraps one provider.

Notification service: full class diagram

NotificationService

+addUser(user)

+notifyUser(key, userId, templateId, values, now) : List<Delivery>

+process(now) : int

Templates

+add(template)

+get(id) : Template

+render(text, values) : String

«value»

Template

+id

+subject

+bodies: Map<ChannelType, String>

«value»

User

+id

+addresses: Map<ChannelType, String>

Delivery

+id

+message: Message

+status: Status

+attempts: int

+nextAttemptAt: long

«value»

RetryPolicy

+maxAttempts

+baseDelay

«interface»

Channel

+type() : ChannelType

+send(message) : Outcome

EmailChannel

SmsChannel

PushChannel

«value»

Message

+type

+to

+subject

+body

«enum»

Outcome

SENT RETRY FAILED

«enum»

Status

PENDING SENDING SENT FAILED

«enum»

ChannelType

EMAIL SMS PUSH

NotificationService

+addUser(user)

+notifyUser(key, userId, templateId, values, now) : List<Delivery>

+process(now) : int

Templates

+add(template)

+get(id) : Template

+render(text, values) : String

«value»

Template

+id

+subject

+bodies: Map<ChannelType, String>

«value»

User

+id

+addresses: Map<ChannelType, String>

Delivery

+id

+message: Message

+status: Status

+attempts: int

+nextAttemptAt: long

«value»

RetryPolicy

+maxAttempts

+baseDelay

«interface»

Channel

+type() : ChannelType

+send(message) : Outcome

EmailChannel

SmsChannel

PushChannel

«value»

Message

+type

+to

+subject

+body

«enum»

Outcome

SENT RETRY FAILED

«enum»

Status

PENDING SENDING SENT FAILED

«enum»

ChannelType

EMAIL SMS PUSH

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
notification-service· read only
AllInOne.java
AllInOne.java
// The complete design, built from every stage on this page.
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.*;
// Many services call notifyUser at once, and several workers call process at once. One lock
// guards the queue and the delivery records. Sending happens outside it: a provider can take
// seconds, and holding the lock that long would stop every other notification.
class NotificationService {
private final Map<ChannelType, Channel> channels = new EnumMap<>(ChannelType.class);
private final Map<String, User> users = new ConcurrentHashMap<>();
private final Templates templates;
private final RetryPolicy retry;
private final Object lock = new Object();
private final List<Delivery> pending = new ArrayList<>(); // waiting for a worker
private final Map<String, List<Delivery>> byKey = new HashMap<>(); // caller's key -> its deliveries
private int nextId = 1;
NotificationService(List<Channel> channels, Templates templates, RetryPolicy retry) {
for (Channel c : channels) this.channels.put(c.type(), c);
this.templates = templates;
this.retry = retry;
}
void addUser(User u) { users.put(u.id(), u); }
// The caller passes a key that names this notification, such as "shipped:order-42". A caller
// that times out and retries sends the same key, and gets the same deliveries back instead of
// a second email. Checking and recording the key is one step under the lock, or two retries
// racing would both get through.
List<Delivery> notifyUser(String key, String userId, String templateId, Map<String, String> values, long now) {
List<Message> messages = render(userId, templateId, values);
synchronized (lock) {
List<Delivery> existing = byKey.get(key);
if (existing != null) return existing;
List<Delivery> queued = new ArrayList<>();
for (Message m : messages) queued.add(new Delivery("D" + nextId++, m, now));
byKey.put(key, queued);
pending.addAll(queued);
return queued;
}
}
// Claim under the lock, send outside it, record under it again. Two workers can never claim the
// same delivery, because claiming changes its status before the lock is released.
int process(long now) {
List<Delivery> claimed = new ArrayList<>();
synchronized (lock) {
for (Delivery d : pending) {
if (d.status == Status.PENDING && d.nextAttemptAt <= now) {
d.status = Status.SENDING;
d.attempts++;
claimed.add(d);
}
}
}
for (Delivery d : claimed) {
Outcome outcome = channels.get(d.message.type()).send(d.message);
synchronized (lock) { record(d, outcome, now); }
}
return claimed.size();
}
// Sent and permanent failures are final. A temporary failure waits longer each time, until
// the retry limit makes it final too.
private void record(Delivery d, Outcome outcome, long now) {
if (outcome == Outcome.SENT) d.status = Status.SENT;
else if (outcome == Outcome.FAILED || d.attempts >= retry.maxAttempts()) d.status = Status.FAILED;
else {
d.status = Status.PENDING;
d.nextAttemptAt = now + retry.delayAfter(d.attempts);
}
if (d.status != Status.PENDING) pending.remove(d);
}
// One message per channel where the user has an address and the template has text. An
// unknown user or template sends nothing.
private List<Message> render(String userId, String templateId, Map<String, String> values) {
User user = users.get(userId);
Template t = templates.get(templateId);
List<Message> out = new ArrayList<>();
if (user == null || t == null) return out;
for (ChannelType type : ChannelType.values()) { // a fixed order, so results are predictable
String body = t.bodies().get(type);
String to = user.addresses().get(type);
if (body == null || to == null || !channels.containsKey(type)) continue;
out.add(new Message(type, to, Templates.render(t.subject(), values), Templates.render(body, values)));
}
return out;
}
}
// The ways we can reach a user. Adding one is a new value here and a new Channel class.
enum ChannelType { EMAIL, SMS, PUSH }
// One message on one channel: where it goes and what it says. SMS ignores the subject.
record Message(ChannelType type, String to, String subject, String body) {}
// Every channel looks the same to the service. Each implementation adapts one provider's API,
// such as an email service, an SMS gateway or a push service, to this one method.
interface Channel {
ChannelType type();
Outcome send(Message m);
}
// A user's address on each channel they've given us: an email address, a phone number, a device
// token. No address on a channel means nothing is sent there.
record User(String id, Map<ChannelType, String> addresses) {}
// One kind of notification, such as "order shipped", written once per channel. An SMS is short;
// an email can say more. A channel with no text here isn't used for this notification.
record Template(String id, String subject, Map<ChannelType, String> bodies) {}
// Thrown when a template names a value the caller didn't give. A bug in the caller, so it is loud.
class MissingValueException extends RuntimeException {
MissingValueException(String name) { super("no value for {{" + name + "}}"); }
}
// Every template, by id, and the code that fills them in.
class Templates {
private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{(\\w+)}}");
private final Map<String, Template> byId = new HashMap<>();
void add(Template t) { byId.put(t.id(), t); }
Template get(String id) { return byId.get(id); }
// Fills in every {{name}}. A placeholder with no value throws: a customer should never get
// "Hi {{name}}, your order {{orderId}} has shipped".
static String render(String text, Map<String, String> values) {
Matcher m = PLACEHOLDER.matcher(text);
StringBuilder out = new StringBuilder();
while (m.find()) {
String value = values.get(m.group(1));
if (value == null) throw new MissingValueException(m.group(1));
m.appendReplacement(out, Matcher.quoteReplacement(value));
}
return m.appendTail(out).toString();
}
}
// What a provider said. RETRY is a temporary problem, such as a timeout or a rate limit, worth
// trying again. FAILED is permanent, such as an invalid phone number, and retrying won't help.
enum Outcome { SENT, RETRY, FAILED }
// SENDING marks a delivery a worker has claimed, so no other worker sends it too.
enum Status { PENDING, SENDING, SENT, FAILED }
// One message's journey: queued, tried one or more times, then sent or given up on.
class Delivery {
final String id;
final Message message;
Status status = Status.PENDING;
int attempts; // tries so far, for the retry limit and the back-off delay
long nextAttemptAt; // not sent again before this time
Delivery(String id, Message message, long now) { this.id = id; this.message = message; this.nextAttemptAt = now; }
}
// Waits twice as long after each failed attempt: 1s, 2s, 4s. A provider that's struggling gets
// room to recover instead of a flood of retries from every sender at once.
record RetryPolicy(int maxAttempts, long baseDelay) {
long delayAfter(int attempts) { return baseDelay << (attempts - 1); }
}
Read onlyJava 21Every file builds in CI13 files165 lines
all_in_one.py
all_in_one.py
Read onlyPython 3.12Every file builds in CI14 files196 lines
AllInOne.cs
AllInOne.cs
Read onlyC# · .NET 10Every file builds in CI13 files163 lines
all_in_one.go
all_in_one.go
Read onlyGo 1.24Every file builds in CI13 files245 lines
all-in-one.ts
all-in-one.ts
Read onlyTypeScript, strictEvery file builds in CI13 files162 lines

Three ideas hold it together:

  • Accept, then send. Accepting a notification is fast, and never fails because of a provider. Sending happens later, as often as it takes. Callers see one method, not templates and queues (Facade).
  • The service knows no providers. Each channel wraps one (Adapter), behind one interface (Strategy). A new channel or a new provider changes nothing else. How long to wait between retries is a swappable policy too.
  • Every delivery has one owner at a time. The queue owns it until a worker claims it, and the worker owns it until it records the outcome.

So far, one caller and one worker. Now many services call at once, and four workers send.

Interviewerminute 20

The order service timed out calling you, and retried. And you now run four workers. Walk me through every way a user could get the same text twice.

What they are testing

Whether you find both duplicates: a caller repeating a request, and two workers sending the same delivery. Then whether the fix for the second keeps the slow send outside the lock, and whether you’re honest about the one duplicate no design here removes.

There are two different duplicates, with two different fixes.

A caller retries. The order service calls notifyUser, the response is lost or slow, and the order service, reasonably, tries again. Without anything to recognise it, the second call queues a second set of messages.

The fix is an idempotency key. The caller names each notification, such as shipped:order-42. A second request with the same key returns the deliveries from the first, and queues nothing. Checking whether the key was seen and recording it must be one step, under the lock. Otherwise two retries arriving together both find the key missing, and both queue.

Two workers take the same delivery. Worker A and worker B both read the queue, both see delivery D7 due, and both send it.

The clearly correct fix is to claim. Under the lock, a worker marks each due delivery SENDING and takes it. Worker B, looking after that, sees SENDING and skips it.

The better fix sends outside the lock. A provider call can take seconds. Holding the lock through it would stop every caller and every other worker for that time. So process holds the lock only to claim, and, after each send, to record the outcome.

Stage 5: shared by many callers and workers

NotificationService

-lock

-pending: List<Delivery>

-byKey: Map<String, List>

+notifyUser(key, userId, templateId, values, now) : List<Delivery>

+process(now) : int

Delivery

+status: Status

+attempts: int

+nextAttemptAt: long

«enum»

Status

PENDING SENDING SENT FAILED

«interface»

Channel

+send(message) : Outcome

NotificationService

-lock

-pending: List<Delivery>

-byKey: Map<String, List>

+notifyUser(key, userId, templateId, values, now) : List<Delivery>

+process(now) : int

Delivery

+status: Status

+attempts: int

+nextAttemptAt: long

«enum»

Status

PENDING SENDING SENT FAILED

«interface»

Channel

+send(message) : Outcome

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Sequence: two workers, one delivery
SMS providerNotificationServiceWorker BWorker ASMS providerNotificationServiceWorker BWorker Aunder the lock, D7 is PENDING, mark it SENDINGunder the lock, D7 is SENDING, skip itprocessprocessnothing claimedsend D7, outside the lockSENTrecord SENT under the lockSMS providerNotificationServiceWorker BWorker ASMS providerNotificationServiceWorker BWorker Aunder the lock, D7 is PENDING, mark it SENDINGunder the lock, D7 is SENDING, skip itprocessprocessnothing claimedsend D7, outside the lockSENTrecord SENT under the lock
Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Background worker
Option How Correct? Cost
1. Claim under a lock, send after (our choice) A SENDING status set under a lock Yes One lock for the queue in one process
2. Hold the lock while sending Simplest Yes One slow provider stalls everything
3. One worker only No sharing at all Yes The speed of one sender
4. A queue that hands out messages A broker gives each message to one consumer Yes The usual shape across many machines
5. Check a “sent” flag before send Read, then send, then set No Two workers both read “not sent”
Go deeperFollowing a retried call and two workers2 min

Let’s put both duplicates through the design.

  1. The order service calls notifyUser("shipped:order-42", ...). Under the lock, the service sees the key is new, records it, and queues deliveries D7 (email) and D8 (SMS). The lock is released, and the response heads back.
  2. The response is lost. The order service times out and calls again with the same key. Under the lock, the key is found. The service returns D7 and D8 again, and queues nothing.
  3. Workers A and B wake up at the same moment. A takes the lock first, finds D7 and D8 due, marks both SENDING, and releases the lock. B takes the lock, finds nothing due (both are SENDING), and goes back to waiting.
  4. A sends D7 and D8, with no lock held. The SMS provider is slow and takes two seconds. During those two seconds, other callers queue new notifications, and B claims and sends them. Nobody waits for the slow SMS.
  5. A takes the lock again to record the outcomes: D7 sent, D8 sent.

Ana gets one email and one text, even though the order service asked twice and two workers were looking at the same queue.

NotificationService.javanotification_service.pyNotificationService.csnotification_service.gonotification-service.ts
// SENDING marks a delivery a worker has claimed, so no other worker sends it too.
enum Status { PENDING, SENDING, SENT, FAILED }
// Many services call notifyUser at once, and several workers call process at once. One lock
// guards the queue and the delivery records. Sending happens outside it: a provider can take
// seconds, and holding the lock that long would stop every other notification.
class NotificationService {
private final Map<ChannelType, Channel> channels = new EnumMap<>(ChannelType.class);
private final Map<String, User> users = new ConcurrentHashMap<>();
private final Templates templates;
private final RetryPolicy retry;
private final Object lock = new Object();
private final List<Delivery> pending = new ArrayList<>(); // waiting for a worker
private final Map<String, List<Delivery>> byKey = new HashMap<>(); // caller's key -> its deliveries
private int nextId = 1;
NotificationService(List<Channel> channels, Templates templates, RetryPolicy retry) {
for (Channel c : channels) this.channels.put(c.type(), c);
this.templates = templates;
this.retry = retry;
}
void addUser(User u) { users.put(u.id(), u); }
// The caller passes a key that names this notification, such as "shipped:order-42". A caller
// that times out and retries sends the same key, and gets the same deliveries back instead of
// a second email. Checking and recording the key is one step under the lock, or two retries
// racing would both get through.
List<Delivery> notifyUser(String key, String userId, String templateId, Map<String, String> values, long now) {
List<Message> messages = render(userId, templateId, values);
synchronized (lock) {
List<Delivery> existing = byKey.get(key);
if (existing != null) return existing;
List<Delivery> queued = new ArrayList<>();
for (Message m : messages) queued.add(new Delivery("D" + nextId++, m, now));
byKey.put(key, queued);
pending.addAll(queued);
return queued;
}
}
// Claim under the lock, send outside it, record under it again. Two workers can never claim the
// same delivery, because claiming changes its status before the lock is released.
int process(long now) {
List<Delivery> claimed = new ArrayList<>();
synchronized (lock) {
for (Delivery d : pending) {
if (d.status == Status.PENDING && d.nextAttemptAt <= now) {
d.status = Status.SENDING;
d.attempts++;
claimed.add(d);
}
}
}
for (Delivery d : claimed) {
Outcome outcome = channels.get(d.message.type()).send(d.message);
synchronized (lock) { record(d, outcome, now); }
}
return claimed.size();
}
// Sent and permanent failures are final. A temporary failure waits longer each time, until
// the retry limit makes it final too.
private void record(Delivery d, Outcome outcome, long now) {
if (outcome == Outcome.SENT) d.status = Status.SENT;
else if (outcome == Outcome.FAILED || d.attempts >= retry.maxAttempts()) d.status = Status.FAILED;
else {
d.status = Status.PENDING;
d.nextAttemptAt = now + retry.delayAfter(d.attempts);
}
if (d.status != Status.PENDING) pending.remove(d);
}
// One message per channel where the user has an address and the template has text. An
// unknown user or template sends nothing.
private List<Message> render(String userId, String templateId, Map<String, String> values) {
User user = users.get(userId);
Template t = templates.get(templateId);
List<Message> out = new ArrayList<>();
if (user == null || t == null) return out;
for (ChannelType type : ChannelType.values()) { // a fixed order, so results are predictable
String body = t.bodies().get(type);
String to = user.addresses().get(type);
if (body == null || to == null || !channels.containsKey(type)) continue;
out.add(new Message(type, to, Templates.render(t.subject(), values), Templates.render(body, values)));
}
return out;
}
}

Why is this correct? A delivery moves from pending to SENDING inside the lock, so only one worker can make that move. Every other worker sees SENDING, and skips it. And a key is checked and recorded in one step, so only one of two matching requests can queue.

What does it cost? A short lock, held only to claim and to record. And there’s one duplicate this can’t remove. If the provider sends the SMS but its response is lost, the worker sees a timeout, retries, and the user gets two. The service can’t know the first one went out. The fix lives at the provider: many accept an idempotency key of their own, and the delivery id is the natural one to pass. That makes the promise “at least once, and usually exactly once”. Say so plainly.

Go deeperHow each language does it1 min

In Java, Python, C# and Go, callers and workers run on their own threads, so the lock is needed. TypeScript runs one callback at a time: notifyUser has no await, so checking the key can’t interleave with another call. But process waits on each send, and another worker runs while it waits, so the claim still matters. TypeScript marks deliveries SENDING before the first await.

The tests use fake channels that record what they were given, and answer with a scripted list of outcomes. They pass now in, so an hour of retries takes a few calls and a millisecond. The tests that matter:

  • A notification goes to each channel the user has an address for, with every placeholder filled.
  • A channel the template doesn’t cover isn’t used.
  • A missing value sends nothing on any channel.
  • An unknown user or template sends nothing.
  • notifyUser only queues; process sends.
  • A temporary failure is retried after one second, not before.
  • It gives up after three attempts, having waited one second and then two.
  • A permanent failure is not retried.
  • The same key twice returns the same deliveries, and sends once.
  • Eight callers send the same 50 keys at once, and four workers process the queue with a slow provider. Each of the 50 messages is sent exactly once per channel.
Go deeperThe tests in five languages2 min

The last test fails in Java, Python, Go and TypeScript if workers don’t claim deliveries, and in Java and Go if the key isn’t checked under the lock.

FakeChannel.javafake_channel.pyFakeChannel.csfake_channel.gofake-channel.ts
// A channel for tests: it records what it sent, and answers with scripted outcomes, then SENT.
static class FakeChannel implements Channel {
final ChannelType type;
final List<Message> sent = Collections.synchronizedList(new ArrayList<>());
final Deque<Outcome> script = new ArrayDeque<>();
boolean slow; // a real provider takes time, which is when two workers can collide
FakeChannel(ChannelType type, Outcome... outcomes) { this.type = type; script.addAll(List.of(outcomes)); }
public ChannelType type() { return type; }
public Outcome send(Message m) {
if (slow) {
try { Thread.sleep(1); } catch (InterruptedException e) { throw new RuntimeException(e); }
}
sent.add(m);
synchronized (script) { return script.isEmpty() ? Outcome.SENT : script.poll(); }
}
}
FakeChannel email = new FakeChannel(ChannelType.EMAIL);
FakeChannel sms = new FakeChannel(ChannelType.SMS);
FakeChannel push = new FakeChannel(ChannelType.PUSH);
Templates templates = new Templates();
{
templates.add(new Template("shipped", "Order {{order}} shipped", Map.of(
ChannelType.EMAIL, "Hi {{name}}, order {{order}} is on its way.",
ChannelType.SMS, "Order {{order}} shipped",
ChannelType.PUSH, "Order {{order}} shipped")));
templates.add(new Template("receipt", "Your receipt", Map.of(ChannelType.EMAIL, "Thanks, {{name}}.")));
}
static final Map<String, String> VALUES = Map.of("name", "Ana", "order", "42");
NotificationService service(FakeChannel... channels) {
NotificationService s = new NotificationService(List.of(channels), templates, new RetryPolicy(3, 1000));
s.addUser(new User("ana", Map.of(ChannelType.EMAIL, "ana@example.com", ChannelType.SMS, "+15550100")));
return s;
}
@Test
void sendsOnEachChannelTheUserHasAnAddressFor() {
NotificationService s = service(email, sms, push);
assertEquals(2, s.notifyUser("k1", "ana", "shipped", VALUES, 0).size()); // no push token
s.process(0);
assertEquals(new Message(ChannelType.EMAIL, "ana@example.com", "Order 42 shipped", "Hi Ana, order 42 is on its way."), email.sent.get(0));
assertEquals("Order 42 shipped", sms.sent.get(0).body());
assertEquals(0, push.sent.size());
}
@Test
void aChannelTheTemplateDoesntCoverIsNotUsed() {
NotificationService s = service(email, sms);
s.notifyUser("k1", "ana", "receipt", VALUES, 0);
s.process(0);
assertEquals(1, email.sent.size());
assertEquals(0, sms.sent.size());
}
@Test
void aMissingValueSendsNothing() {
NotificationService s = service(email, sms);
assertThrows(MissingValueException.class, () -> s.notifyUser("k1", "ana", "shipped", Map.of("name", "Ana"), 0));
assertEquals(0, s.process(0));
assertEquals(0, email.sent.size());
}
@Test
void anUnknownUserOrTemplateSendsNothing() {
NotificationService s = service(email);
assertEquals(0, s.notifyUser("k1", "bob", "shipped", VALUES, 0).size());
assertEquals(0, s.notifyUser("k2", "ana", "nope", VALUES, 0).size());
}
@Test
void notifyQueuesAndProcessSends() {
NotificationService s = service(email);
Delivery d = s.notifyUser("k1", "ana", "shipped", VALUES, 0).get(0);
assertEquals(Status.PENDING, d.status);
assertEquals(0, email.sent.size());
assertEquals(1, s.process(0));
assertEquals(Status.SENT, d.status);
}
@Test
void aTemporaryFailureIsRetriedAfterABackoff() {
FakeChannel flaky = new FakeChannel(ChannelType.EMAIL, Outcome.RETRY);
NotificationService s = service(flaky);
Delivery d = s.notifyUser("k1", "ana", "receipt", VALUES, 0).get(0);
s.process(0);
assertEquals(0, s.process(999)); // not due yet
assertEquals(1, s.process(1000));
assertEquals(Status.SENT, d.status);
assertEquals(2, d.attempts);
}
@Test
void itGivesUpAfterTheLastAttempt() {
FakeChannel down = new FakeChannel(ChannelType.EMAIL, Outcome.RETRY, Outcome.RETRY, Outcome.RETRY, Outcome.RETRY);
NotificationService s = service(down);
Delivery d = s.notifyUser("k1", "ana", "receipt", VALUES, 0).get(0);
s.process(0);
s.process(1000); // waited 1s
assertEquals(0, s.process(2999));
s.process(3000); // waited 2s more
assertEquals(Status.FAILED, d.status);
assertEquals(3, d.attempts);
assertEquals(0, s.process(100_000));
}
@Test
void aPermanentFailureIsNotRetried() {
FakeChannel bad = new FakeChannel(ChannelType.EMAIL, Outcome.FAILED);
NotificationService s = service(bad);
Delivery d = s.notifyUser("k1", "ana", "receipt", VALUES, 0).get(0);
s.process(0);
assertEquals(Status.FAILED, d.status);
assertEquals(1, d.attempts);
}
@Test
void theSameKeyNotifiesOnce() {
NotificationService s = service(email);
List<Delivery> first = s.notifyUser("shipped:42", "ana", "receipt", VALUES, 0);
assertSame(first, s.notifyUser("shipped:42", "ana", "receipt", VALUES, 0));
s.process(0);
assertEquals(1, email.sent.size());
}
@Test
void concurrentCallersAndWorkersSendEachMessageExactlyOnce() throws Exception {
email.slow = sms.slow = true;
NotificationService s = service(email, sms);
List<Thread> callers = new ArrayList<>();
for (int t = 0; t < 8; t++) {
callers.add(Thread.ofPlatform().start(() -> {
for (int i = 0; i < 50; i++) s.notifyUser("k" + i, "ana", "shipped", Map.of("name", "Ana", "order", "" + i), 0);
}));
}
for (Thread t : callers) t.join();
List<Thread> workers = new ArrayList<>();
for (int w = 0; w < 4; w++) workers.add(Thread.ofPlatform().start(() -> { while (s.process(0) > 0) { } }));
for (Thread t : workers) t.join();
assertEquals(50, email.sent.size());
assertEquals(50, sms.sent.size());
assertEquals(50, new HashSet<>(sms.sent).size());
}
Go deeperEvery edge case, and where it is handled2 min
Case What happens Where
Unknown user or template Nothing queued render
User has no address on a channel That channel is skipped render
Template has no body for a channel That channel is skipped render
A placeholder with no value An error; nothing queued on any channel Templates.render
Provider times out Retried after 1 s, 2 s, 4 s RetryPolicy
Invalid phone number Failed at once, not retried Outcome.FAILED
Still failing after the last attempt Marked failed process
Caller sends the same key twice Same deliveries returned, nothing new queued byKey
Two workers at once Each delivery claimed by one SENDING
Provider sent it, but the response was lost Retried; may duplicate unless the provider dedupes the provider’s key
Decision We chose We gave up
Channels One interface; each class wraps one provider Calling providers directly
Wording Templates per channel; every value required Callers writing their own text
Sending Queue now, send later Knowing at once whether it was delivered
Failures Retry temporary ones with doubling waits, then give up Guaranteed delivery
Repeated calls An idempotency key, checked under the lock Nothing: callers must send a key
Several workers Claim under the lock, send outside it A broker’s scale, until we need it
Go deeperGenuinely different designs, and when to pick them2 min

A message broker between the halves. At scale, notifyUser publishes to a queue such as Kafka, SQS or RabbitMQ, one topic per channel, and workers on many machines consume. The broker does the claiming: it hands each message to one consumer, and delivers it again if that consumer dies. That’s option 4 from the table, and it’s what real notification systems use.

A third-party notification platform. Services like Twilio, SendGrid or a hosted notification platform already provide templates, preferences, retries and analytics. For a small team, adopting one is often the right call, and the design above becomes a thin adapter to it.

Event-driven notifications. Instead of services calling notifyUser, the notification service listens for events, such as “order shipped”, and decides itself who to tell. Callers no longer need to know about notifications at all; see the pub-sub page.

  • 0 to 3. Channels; who writes the text; does the caller wait; what failure means.
  • 3 to 5. The promises. Name “never twice, never half-filled” as the hard one.
  • 5 to 22. One email sender and its problems, the channel interface, templates per channel, then the queue with retries and backoff. Code process.
  • 22 to 24. Walk through the full diagram, and why accepting and sending are separate.
  • 24 to 29. The two duplicates, the key, the claim, and the duplicate nobody removes.
  • 29 to 45. Preferences and fallback, then your own questions.

If time is short, describe templates in a sentence, and spend the time on retries and duplicates. Those decide this question for most interviewers.

Interviewerminute 28

Users want to turn off marketing messages but keep security alerts. And if SMS fails completely, fall back to email. Where do those go?

What they are testing

Whether preferences become a check before queuing, by category, and whether a fallback is a new delivery created when one fails, not a special case inside a channel.

Most extensions fit into one place:

Extension What changes Touches sending?
Opt-outs by category A check in render No
Fallback to another channel A new delivery when one fails No
Quiet hours nextAttemptAt starts at the end of the user’s quiet hours No
Rate limit per user A counter checked when queuing No
A new channel, such as WhatsApp A new Channel class No
Two SMS providers, with failover A Channel that tries one provider, then the other No
Scheduled sends notifyUser takes a send-at time as now No
A queue that survives restarts The pending list moves into a database or message broker Storage only
Go deeperAdding opt-outs and fallback, step by step2 min

Opt-outs.

  1. Give each template a category, such as security, orders or marketing.
  2. Give each user a set of opted-out categories, per channel. Ana might turn off marketing by SMS, but keep it by email.
  3. Check in render. Before building a channel’s message, skip it if the user opted out of that category on that channel. Nothing is queued, so nothing is sent.
  4. Some categories can’t be turned off. A security alert must always reach the user. That’s a property of the category, not of the user, so the check simply ignores opt-outs for it.

Fallback.

  1. Give the service a fallback order, for example SMS, then email.
  2. When a delivery is finally marked failed, look up the next channel in the order.
  3. If the template has a body for it, and the user has an address, render it, and queue a new delivery. It gets its own retries, and its own id.
  4. The channels never know about each other. The SMS channel just reports FAILED. The decision to try email lives in one place, in the service.

Both changes sit before the queue or after a delivery ends. The worker loop, the claim and the retry policy don’t change.

Go deeperMaking the queue survive a restart, step by step2 min
  1. Store deliveries in a database table, with the id, the message, the status, the attempt count, and the next attempt time. notifyUser inserts rows instead of adding to a list.
  2. Store keys in the same table, with a unique index. A second insert with the same key fails, and the service returns the existing rows. The database’s index replaces our lock for this check, and works across many servers.
  3. Claim with one update. A worker runs something like UPDATE deliveries SET status = 'SENDING', claimed_at = now() WHERE id IN (SELECT id ... WHERE status = 'PENDING' AND next_attempt_at <= now() LIMIT 20 FOR UPDATE SKIP LOCKED). SKIP LOCKED lets several workers claim different rows at the same time, without waiting on each other.
  4. Send, then record, in a separate short update, exactly as now.
  5. Sweep stuck deliveries. A worker that crashes mid-send leaves rows in SENDING. A job moves rows that have been SENDING for too long back to PENDING. This is where the lost-response duplicate becomes more likely, so passing the delivery id to the provider as its key matters even more.

The shape of the design doesn’t change: accept, claim, send outside any lock, record. Only where the state lives changes.

Go deeperFour more follow-up questions2 min
Interviewer asksWhat happens if the service restarts with messages in the queue?

In this design, they’re lost, because the queue is in memory. A real service keeps deliveries in a database table or a broker that saves to disk. A worker that dies mid-send leaves its delivery in SENDING, so a cleanup job returns deliveries stuck in SENDING for too long to PENDING. That’s another reason the provider’s idempotency key matters.

Interviewer asksHow do you send one announcement to ten million users?

Not as ten million calls from the caller. A bulk job takes the template and an audience, and queues deliveries in batches, at a rate the providers allow. Providers have send limits, so the workers limit their rate per provider, and the announcement takes as long as it takes.

Interviewer asksWhere do you check that a push token is still valid?

The push provider tells you: sending to an uninstalled app returns an error saying the token is dead. The push channel reports that as FAILED, and a listener removes the token from the user’s addresses, so the next notification skips it.

Interviewer asksWhy not let each service send its own emails?

Then every service needs every provider’s passwords, its own retry logic, its own templates and its own duplicate handling. Users get messages that don’t match, and no single place to opt out. Notifications are a shared concern, so they get one owner.