If you remember five things
The whole answer in one glance. Everything below is the defense of these five calls.
- Content is data, delivery is behavior. Split
NotificationfromNotificationChannel. Fusing them is the exact mistake that makes the design impossible to extend. - Strategy on the channel. Each channel is an interchangeable algorithm. Adding WhatsApp is one new class and one line in a list, never an edit to existing code.
- All policy in the orchestrator. Preferences, quiet hours, retries, and rate limits live in
NotificationService. Channels stay dumb, so none of them ever grows anif channel == SMSbranch. - One event, many outcomes.
sendreturns a list ofDeliveryResult, one per channel. Partial failure is normal and you never pretend it is a transaction. - Fail safe under concurrency. Guarded status transitions, an idempotency key to stop double-sends, stateless channels you can share across threads.
1Clarify the requirements#
Before I draw a single box, I ask questions. "Notification system" means five different things depending on who is asking, so I pin it down in the first 60 seconds. This part is scored: the interviewer watches whether you scope before you code.
What I ask, and what each answer locks in:
- Which channels? Email, SMS, push, in-app. I assume all four, and I assume a fifth (WhatsApp, Slack) ships next quarter. The design has to make that a one-class change.
- Does one notification span multiple channels? For example, "payment failed" hits email and push at once. Yes. This single answer shapes the whole entity model.
- Do users have preferences? Can someone mute SMS but keep email? Assume yes. This is where real systems get interesting and where a naive design falls apart.
- Fire-and-forget, or track status? We track status: CREATED, SENT, DELIVERED, FAILED. Each one is a real state a product team will query.
- Retries on failure? Yes, with a hard cap on attempts so a dead provider cannot loop forever.
- Rate limiting? We do not send one person 40 emails a minute. Basic throttling, per user.
Functional scope lands at: build a notification from an event, pick channels from user preferences, render channel-specific content, send it, retry on failure, record the outcome.
Non-functional scope is three words I keep coming back to:
- Extensible. Adding a WhatsApp channel must not touch existing code.
- Testable. A unit test must never open a real SMTP socket.
- Safe under concurrency. Two threads sending to the same user must not corrupt state or double-send.
One boundary I set out loud up front:
- I am designing the library and object model, not the distributed delivery pipeline.
- If they want the queue-and-worker story, that is a system design conversation, and I flag exactly where this object model plugs into it (section 7).
- Naming that boundary early stops the interview from drifting.
This opening is scored before you write a line of code. I am listening for whether you scope the thing yourself and name the extensibility bar on your own. Start coding an EmailSender in minute two and I have quietly downgraded you. Pin the channels, the multi-channel question, and preferences first, because those three answers decide your entity model.
2Identify the core entities#
Think in nouns, and the one job each noun owns. If a noun owns two jobs, split it.
- Notification. The thing we are sending: a recipient, a payload, a priority, a status. It does not know how to send itself. That one separation is the difference between a design that extends and one that rots.
- Recipient / User. Who gets it, plus contact details and preferences.
- NotificationChannel. The strategy that delivers over a medium: Email, SMS, Push, InApp. Each knows how to talk to its own provider and nothing else.
- ChannelPreference. Per-user, per-channel on/off plus quiet hours. Keeps policy out of the sender.
- MessageTemplate. Turns structured event data into channel-specific content. An SMS is 160 characters, an email has a subject and a body. Same source data, different render.
- NotificationService. The orchestrator: takes a request, resolves channels, renders content, dispatches, handles retries. All the policy lives here.
- DeliveryResult. The outcome of one send attempt on one channel: success flag, provider id, error reason. One per channel per attempt.
Content is data, delivery is behavior. Notification carries what to say and what state it is in. NotificationChannel carries how to ship it. Mix them and you get a Notification.sendEmail() method, which is the exact mistake that makes the design impossible to extend. This is the first thing a good interviewer probes for, so name the split before they ask.
3Class design#
The spine is the Strategy pattern on NotificationChannel. The service holds a map of channels keyed by type and picks which to fire from the user's preferences. Each channel is independent and swappable, so a provider swap or a new channel never ripples outward.
How the flow reads, top to bottom:
- A
NotificationRequestcomes in carrying the caller's intent. - The service resolves it against the user's
ChannelPreferenceset. - It renders a
MessageTemplateper surviving channel and dispatches. - Every attempt produces a
DeliveryResult, and aRetryPolicydecides whether we go again.
On Notification itself:
- It is effectively immutable on content: once built, the content does not change, only the status does.
- Status transitions run through a small state model, so no stray line can jump from FAILED straight to DELIVERED by accident.
NotificationService composes over the NotificationChannel interface (the hollow diamond), never over the concrete EmailChannel. That is why a new channel is a pure addition and the service never names a concrete type.Read the arrows:
NotificationServicehas channels (composition over the interface, not the concretes).- It uses a
RetryPolicyand aMessageTemplate. - The three concrete channels are a
NotificationChannel. - The service never names
EmailChanneldirectly, and that is the property that makes a new channel a pure addition.
4Key implementation#
Start with the channel abstraction. This is the seam that makes everything else bend without breaking, so I write it first on the board.
public enum ChannelType { EMAIL, SMS, PUSH, IN_APP }
public interface NotificationChannel {
ChannelType type();
DeliveryResult deliver(Notification notification);
}
A concrete channel owns one provider and nothing else. Notice what is not here: it does not touch preferences, it does not retry, it does not know about other channels. Its one job is to hand a message to a provider and report back.
public class EmailChannel implements NotificationChannel {
private final EmailProvider provider; // SMTP, SES, whatever
public EmailChannel(EmailProvider provider) {
this.provider = provider;
}
@Override public ChannelType type() { return ChannelType.EMAIL; }
@Override public DeliveryResult deliver(Notification n) {
try {
String providerId = provider.sendEmail(
n.recipientAddress(), n.subject(), n.content());
return DeliveryResult.success(ChannelType.EMAIL, providerId);
} catch (ProviderException e) {
return DeliveryResult.failure(ChannelType.EMAIL, e.getMessage());
}
}
}
Why the channel returns data instead of throwing:
- The channel never throws past its own boundary.
- A provider failure becomes a
DeliveryResult.failure, a value the orchestrator can inspect and act on. - Returning data instead of throwing is what lets me handle partial failure cleanly later.
The Notification keeps its status honest with a tiny state machine. I do not want random code flipping it to DELIVERED, so every transition is guarded and throws on an illegal move.
public class Notification {
public enum Status { CREATED, SENT, DELIVERED, FAILED }
private final String id;
private volatile Status status = Status.CREATED;
private final String content;
public synchronized void markSent() {
if (status != Status.CREATED)
throw new IllegalStateException("cannot send from " + status);
status = Status.SENT;
}
public synchronized void markDelivered() {
if (status != Status.SENT)
throw new IllegalStateException("cannot deliver from " + status);
status = Status.DELIVERED;
}
public synchronized void markFailed(String reason) {
status = Status.FAILED;
}
public Status status() { return status; }
}
The synchronized plus volatile is not decoration:
- Two threads must not race a transition.
- A reader on another thread must see the latest status.
- I point that out before the interviewer asks.
Reveal the answer
Now the orchestrator. It resolves channels against preferences, renders, dispatches, and retries. The policy lives here and nowhere else. If you remember one class from this answer, remember that all the if statements live in this one.
public class NotificationService {
private final Map<ChannelType, NotificationChannel> channels;
private final MessageTemplate templates;
private final RetryPolicy retryPolicy;
public NotificationService(List<NotificationChannel> channelList,
MessageTemplate templates,
RetryPolicy retryPolicy) {
this.channels = channelList.stream()
.collect(Collectors.toMap(NotificationChannel::type, c -> c));
this.templates = templates;
this.retryPolicy = retryPolicy;
}
public List<DeliveryResult> send(NotificationRequest req) {
List<DeliveryResult> results = new ArrayList<>();
for (ChannelType type : resolveChannels(req.recipient())) {
NotificationChannel channel = channels.get(type);
if (channel == null) continue; // unsupported, skip
String content = templates.render(type, req.data());
Notification n = new Notification(req, type, content);
results.add(dispatchWithRetry(channel, n));
}
return results;
}
private DeliveryResult dispatchWithRetry(NotificationChannel channel,
Notification n) {
DeliveryResult result = null;
for (int attempt = 1; ; attempt++) {
n.markSent();
result = channel.deliver(n);
if (result.success()) {
n.markDelivered();
return result;
}
n.markFailed(result.error());
if (!retryPolicy.shouldRetry(result, attempt)) return result;
// real system: backoff + enqueue, not a tight loop
}
}
private List<ChannelType> resolveChannels(User user) {
return user.preferences().stream()
.filter(ChannelPreference::enabled)
.filter(ChannelPreference::allowsNow) // quiet hours
.map(ChannelPreference::type)
.collect(Collectors.toList());
}
}
Two things I call out here:
sendreturns oneDeliveryResultper channel: one event, several outcomes, which mirrors reality.resolveChannelsis where preferences and quiet hours get applied, so the channels stay dumb.- The comment on the retry loop is honest about the shortcut: in production that is a backoff and an enqueue, not a spin, and I say so rather than pretend the tight loop ships.
5Design decisions and patterns used#
Name the pattern where it earns its place, and skip the ones that do not fit. Here is the short list and why each one is here.
- Strategy on
NotificationChannel. The heart of it. Each channel is an interchangeable delivery algorithm. Adding WhatsApp means writing one class and registering it in the list passed to the constructor. No existing file changes. When the interviewer asks "how do you add a new channel," I point here and I am done in one sentence. - State on
Notification. Guarded transitions stop invalid moves like FAILED to DELIVERED, throwingIllegalStateExceptioninstead of silently corrupting status. It is a lightweight version, not a full state-pattern class per state, but naming the pattern shows the intent. - Factory for building channels. In the constructor I assemble a map from a list. In a larger system a
ChannelFactorywires each channel with its provider config, so the service never news up a provider itself. That keeps provider credentials and config out of the orchestrator. - Template Method / Builder around
MessageTemplate. Rendering per channel keeps content formatting out of the sender. SMS gets 160 characters, email gets a subject line, same source data, different output. The sender never formats a string. - Dependency injection everywhere. The service takes channels, templates, and the retry policy in its constructor. That is precisely what makes it testable: in a unit test I pass a fake channel that records calls and returns a canned
DeliveryResult, and I never touch a real provider. If the servicenewed up a realEmailChannel, the test would need a live SMTP server, and now it is not a unit test.
One more split I defend: NotificationRequest stays separate from Notification.
- The request is the caller's intent: one event aimed at a user.
- The notification is one concrete message on one channel.
- Collapsing them feels simpler for about ten minutes, then multi-channel sending becomes a mess because a single object cannot carry three channel-specific contents and three statuses at once.
6Handling edge cases and concurrency#
This is where a Medium problem earns its grade. The entity model is table stakes; the failure thinking is what separates a hire from a no-hire.
Partial failure.
- Email succeeds, SMS fails. I do not roll anything back.
- Each channel is independent, so I return a list of results and let the caller decide what a partial failure means for its use case.
- The notification is not one atomic thing, it is several, and pretending otherwise invents a transaction that does not exist.
Double-send under concurrency.
- If two threads process the same event, you can send twice.
- The clean fix is an idempotency key on
NotificationRequest. - Before dispatch, check a store (a concurrent set in-process, or a DB unique constraint across instances) keyed by
(userId, eventType, dedupeKey). First writer wins, the rest no-op.
public boolean claim(String idempotencyKey) {
return sentKeys.add(idempotencyKey); // ConcurrentHashMap-backed set
}
Set.addreturning false means the key was already there, so the caller skips the send.- That single atomic check is the whole dedupe.
- Across many instances I swap the in-memory set for a unique row in the database, same logic, durable.
Rate limiting.
- I do not flood a user with 40 emails a minute. A per-user token bucket sits in front of dispatch.
- It must be thread-safe, so I use an
AtomicLongfor the token count plus a last-refill timestamp, or a ready-made limiter. - In the interview I describe the bucket in two sentences and move on. I do not burn five minutes implementing it unless asked.
Retry storms.
- The tight retry loop in my sample is a placeholder and I flag it as one.
- In production the failed attempt goes back on a queue with exponential backoff and a hard max-attempt cap, so a dead provider cannot peg a thread.
- The
RetryPolicyobject is the exact seam where that logic lives, so swapping a spin for a backoff is a one-class change, not a rewrite.
Quiet hours and preferences changing mid-flight.
- I read preferences once at
resolveChannelstime and snapshot them. - If a user flips a setting while a notification is in flight, we honor what we read.
- Re-checking mid-send invites races for no real benefit, and "we used the preferences as of send time" is a defensible answer in a product review.
Shared mutable channel state.
- Channels should be stateless.
- If
EmailChannelholds an SMTP connection pool, that pool must be thread-safe, but the channel object itself carries no per-request state. - Stateless channels mean I share one
EmailChannelinstance across all threads safely, which is why the constructor builds one map and reuses it rather than creating channels per request.
Assuming the happy path. Notification systems fail constantly and silently, so if you dispatch three channels and never mention partial failure, idempotency, or retry caps, I have my answer. Walk through "email sent, SMS dead, push rate-limited, same event fired twice" on your own, out loud. Candidates who wait to be asked about failure are the ones who have never run one of these in production.
7Extensions and follow-ups#
When the interviewer pushes, this is where I take it. Each one should slot in without reshaping the core, and I say so as I describe it.
- Scheduled and recurring notifications. Add a
scheduledAtto the request and a scheduler that enqueues at the right time. The object model does not change, I am just feeding the service later. - Batching and digests. Instead of 20 emails, send one summary. That is an aggregation layer that collects notifications per user over a window, then renders a digest template. It slots in before dispatch and the channels never know the difference.
- Provider fallback. If the primary SMS provider is down, fail over to a secondary. I wrap providers in a chain inside the channel, or make the channel a composite over two providers. Strategy makes this painless because nothing outside the channel cares which provider actually sent.
- Priority. A
Priorityon the request lets urgent notifications jump the queue. The object model already carries it, and the dispatch layer honors it when it becomes a real queue. - Delivery receipts. Real channels confirm delivery asynchronously via webhook. That is where
markDeliveredactually gets called, not inline on the API return. The status moves from SENT to DELIVERED on the provider webhook, so the synchronousdeliverreturn really means "accepted by provider," and DELIVERED is a later, separate event.
When they want scale, I bridge to system design right here:
- The service becomes a producer onto a message queue.
- Channels become worker pools.
- The status store becomes a real database.
- The class model survives that transition intact, which is exactly the point of designing it this way. Nothing I wrote gets thrown out to scale it, and I make sure they hear that.
A good LLD answer scales into a system design answer without a single class getting deleted.
"Add WhatsApp. Walk me through exactly what changes."
"Email sent but SMS failed for the same event. What state is the notification in?"
"Two threads process the same event. How do you stop a double-send?"
"Your retry loop is a tight spin. That ships?"
"A user flips off SMS while a notification is already in flight. What happens?"
8What the interviewer is really checking#
They are grading one instinct above all: do you separate what to send from how to send it. Everything else follows from that.
- The data/behavior split. If your
Notificationhas asendEmail()method, that is an instant red flag and they will poke until it hurts, because that one method proves you fused data and behavior. - Is adding a channel a one-class change? They will literally say "add WhatsApp" and watch your face. If your answer is one class and one registration line, you pass this probe cold.
- Did you think about failure? Notification systems fail constantly and silently. A candidate who assumes the happy path is a liability, so they want to hear partial failure, idempotency, and retry caps before they ask.
- Right pattern by instinct, or pattern name-dropping? Naming Strategy where Strategy belongs beats naming five patterns that do not fit.
And say your trade-offs out loud. "I am keeping retries out of the channel because the channel should not know about policy." That one sentence is worth more than another class on the board, because it shows you are making a choice, not just drawing shapes.
What you can now explain in the room
- Why content is data and delivery is behavior, and what a `Notification.sendEmail()` method would break.
- Why Strategy on the channel makes adding WhatsApp a one-class change with no edits to existing code.
- Why every `if` (preferences, quiet hours, retries, rate limits) belongs in the orchestrator and nowhere else.
- Why `send` returns a list of results and why partial failure is not a transaction to roll back.
- How an idempotency key stops a double-send, in-process and across instances.
- Why `synchronized` plus `volatile` on status transitions, and why channels stay stateless and shared.
If you liked this, the Strategy and Observer patterns show up again in "Design a Logging Framework" and "Design a Rate Limiter," and the state-transition thinking carries straight into "Design a Vending Machine."
Get a prep plan built around your Design a Notification System interview
Tell us the role and your interview date. We will send back a dated, day by day plan. Free.