Lesson 29 of 31
Design a Notification Service in Java
A notification service takes "tell this user their order shipped" and turns it into an email, an SMS and a push message, respecting what the user allows and surviving flaky providers. This article is the class-level design: the types, interfaces and control flow inside one process. The architecture around it (queues, workers, provider failover, scale) is covered in the system-design counterpart, design a notification system. The steps follow the LLD interview framework.
Requirements
Functional
- Send a notification to a user over email, SMS and push. Adding a channel later must not change existing code paths.
- Message text comes from a template with named placeholders, for example
Order {order} has shipped. - Users set preferences per category (security, orders, marketing) and channel. Security messages ignore preferences.
- A failed send is retried with exponential backoff, but only when the failure is transient. A permanent failure (for example a blocked number) stops immediately.
- Interested parties (logging, metrics) can observe each attempt and each final outcome.
- The caller gets one outcome per channel: delivered, failed or skipped, with the reason.
Non-functional and scope
- Tests must run instantly and deterministically: no real network, no real sleeping.
- Single process, synchronous. Queues, worker pools, rate limits and provider failover are extensions.
Entities
classDiagram
class NotificationService {
-Map~Channel, Sender~ senders
-Map~String, Template~ templates
-Map~UserId, User~ users
-RetryPolicy policy
+notify(user, category, template, vars) Map~Channel, Outcome~
}
class Channel {
<<enum>>
EMAIL
SMS
PUSH
}
class Sender {
<<interface>>
+send(to, Rendered) SendResult
}
class Template {
+render(vars) Rendered
}
class Preferences {
+allows(Category, Channel) boolean
}
class RetryPolicy {
+delay(failedAttempts) Duration
}
class Sleeper {
<<interface>>
}
class DeliveryObserver {
<<interface>>
+onEvent(DeliveryEvent)
}
class Outcome {
<<sealed interface>>
Delivered
Failed
Skipped
}
NotificationService o-- Sender
NotificationService *-- Template
NotificationService *-- Preferences
NotificationService --> RetryPolicy
NotificationService ..> Sleeper
NotificationService o-- DeliveryObserver
NotificationService ..> Outcome
Sender <|.. FakeSenderWhich noun becomes what (modeling domains in Java):
Channel,Category,SkipReasonare enums: closed sets of plain constants.SendResult,SendError,Outcome,DeliveryEventare sealed interfaces with record variants: closed sets whose variants carry different data.Senderis an interface: real providers are open-ended, and tests supply fakes. It is the one place the outside world enters.Sleeperis an interface for the same reason. Backoff that really sleeps makes tests slow.DeliveryObserveris an interface because observers are added by other teams. See event bus and observers.Template,Preferences,RetryPolicyare immutable records or small classes.
API
Map<Channel, Outcome> NotificationService.notify(
UserId user, Category category, String template, Map<String, String> vars) // throws NotifyException
interface Sender { SendResult send(String to, Rendered message); }
interface Sleeper { void sleep(Duration duration); }
interface DeliveryObserver { void onEvent(DeliveryEvent event); }notify throws an unchecked NotifyException only for problems with the request itself (unknown user, unknown template, missing variable). Those are caller mistakes that no retry can fix. Delivery problems are data, reported per channel in the returned map, because one channel failing must not hide the others.
Design decisions
Channels behind an interface, kinds as an enum
The dispatcher needs to ask "which channels does this user allow?" and to store a sender per channel. Both are closed questions about the three kinds, so Channel is an enum and can key an EnumMap, which iterates in declaration order. Sending itself is open behavior, so each Channel maps to a Sender. A test keeps a reference to its fake and inspects it after the service has used it. The fake scripts its failures, which makes retry behavior testable.
send returns a sealed SendResult instead of throwing. A failed send is an expected outcome that the retry loop handles on every call, so it is a value in the type, and a switch over it must handle every case. An adapter around a real provider SDK catches the SDK's exceptions and converts them to a SendResult.
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
public class Main {
sealed interface SendResult {
record Sent() implements SendResult {}
}
sealed interface SendError extends SendResult {
record Transient(String reason) implements SendError {}
record Permanent(String reason) implements SendError {}
}
interface Sender {
SendResult send(String to, String body);
}
static class FakeSender implements Sender {
private final Deque<SendError> failures;
private final List<String> delivered = new ArrayList<>();
FakeSender(List<SendError> failures) {
this.failures = new ArrayDeque<>(failures);
}
@Override
public synchronized SendResult send(String to, String body) {
var failure = failures.poll();
if (failure != null) {
return failure;
}
delivered.add(to + ": " + body);
return new SendResult.Sent();
}
synchronized List<String> delivered() {
return List.copyOf(delivered);
}
}
public static void main(String[] args) {
var sender = new FakeSender(List.of(new SendError.Transient("timeout")));
System.out.println(sender.send("a@example.com", "hello"));
System.out.println(sender.send("a@example.com", "hello"));
System.out.println("delivered: " + sender.delivered());
}
}Transient[reason=timeout]
Sent[]
delivered: [a@example.com: hello]The error type separates transient failures (timeouts, provider 503) from permanent ones (invalid address). That split is what lets the retry loop decide by pattern matching instead of parsing strings. See errors in domain design.
Templates render once, before any channel
A template is text with {name} placeholders. Rendering happens once per notification, before the channel loop, so a missing variable fails the whole request before anything is sent. A half-sent notification with a broken message on one channel is the outcome to avoid. The function throws an exception naming the variable.
import java.util.Map;
public class Main {
static class TemplateException extends RuntimeException {
TemplateException(String message) {
super(message);
}
}
static String fill(String text, Map<String, String> vars) {
var out = new StringBuilder();
int i = 0;
while (i < text.length()) {
char c = text.charAt(i);
if (c != '{') {
out.append(c);
i++;
continue;
}
int close = text.indexOf('}', i + 1);
if (close < 0) {
throw new TemplateException("unclosed placeholder");
}
String name = text.substring(i + 1, close);
String value = vars.get(name);
if (value == null) {
throw new TemplateException("missing variable: " + name);
}
out.append(value);
i = close + 1;
}
return out.toString();
}
static void show(String text, Map<String, String> vars) {
try {
System.out.println(fill(text, vars));
} catch (TemplateException e) {
System.out.println("error: " + e.getMessage());
}
}
public static void main(String[] args) {
var vars = Map.of("order", "1042", "name", "Ada");
show("Hi {name}, order {order} has shipped.", vars);
show("Order {order} arrives on {date}.", vars);
show("Order {order", vars);
}
}Hi Ada, order 1042 has shipped.
error: missing variable: date
error: unclosed placeholderUser preferences and mandatory categories
Preferences map a category to the set of channels the user accepts. The rule for each channel is: skip it if the category is optional and the user did not enable that channel; skip it if the user has no address for it; otherwise send. Security notices (password reset, sign-in alerts) are mandatory and bypass the opt-out check, while still requiring an address. Encoding "mandatory" as a method on the Category enum keeps the rule in one place.
Retry with exponential backoff as a policy value
The retry rule is data: a maximum number of attempts, a base delay, a growth factor and a cap. The delay after the n-th failure is min(cap, base * factor^(n-1)). Making this a record means a caller can pass a different policy without touching the dispatch loop, and a test can assert the exact delays.
import java.time.Duration;
import java.util.ArrayList;
public class Main {
record RetryPolicy(int maxAttempts, Duration base, long factor, Duration cap) {
RetryPolicy {
if (maxAttempts < 1 || factor < 1 || base.isNegative() || cap.isNegative()) {
throw new IllegalArgumentException("invalid retry policy");
}
if (cap.toMillis() > Long.MAX_VALUE / factor) {
throw new IllegalArgumentException("cap times factor must fit in a long");
}
}
/** Delay to wait after the given number of failed attempts (1-based). */
Duration delay(int failedAttempts) {
long ms = base.toMillis();
long capMs = cap.toMillis();
for (int i = 1; i < failedAttempts && ms < capMs; i++) {
ms = Math.min(capMs, ms * factor);
}
return Duration.ofMillis(Math.min(ms, capMs));
}
}
public static void main(String[] args) {
var policy = new RetryPolicy(8, Duration.ofMillis(100), 2, Duration.ofSeconds(5));
var delays = new ArrayList<Long>();
for (int n = 1; n < policy.maxAttempts(); n++) {
delays.add(policy.delay(n).toMillis());
}
System.out.println(delays);
System.out.println("no overflow at attempt 200: " + policy.delay(200).toMillis());
}
}[100, 200, 400, 800, 1600, 3200, 5000]
no overflow at attempt 200: 5000The loop stops growing once the delay reaches the cap, and the constructor rejects a cap so large that cap * factor could overflow a long. Together they keep a large attempt count from overflowing. Production systems add random jitter to each delay so many clients that failed together do not retry together; it is omitted here so the output is deterministic, and it slots in as one more field on the policy plus an injected RandomGenerator. The delay itself is spent through a Sleeper interface, so tests record delays instead of waiting. A production Sleeper calls Thread.sleep.
Observers see every attempt
The service emits a DeliveryEvent for each failed attempt and for each final outcome, and calls every registered DeliveryObserver. The service does not know whether the observer logs, counts metrics or pages someone. DeliveryObserver has one method, so a lambda can implement it. An observer that records events guards its own state. See event bus and observers.
Full implementation
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
enum Channel { EMAIL, SMS, PUSH }
enum Category {
SECURITY, ORDERS, MARKETING;
boolean mandatory() {
return this == SECURITY;
}
}
record UserId(int id) {}
sealed interface SendResult {
record Sent() implements SendResult {}
}
sealed interface SendError extends SendResult {
record Transient(String reason) implements SendError {}
record Permanent(String reason) implements SendError {}
}
enum SkipReason { OPTED_OUT, NO_ADDRESS, NO_SENDER }
sealed interface Outcome {
record Delivered(int attempts) implements Outcome {}
record Failed(int attempts, SendError error) implements Outcome {}
record Skipped(SkipReason reason) implements Outcome {}
}
/** Thrown when the request itself is wrong. Delivery problems are reported as Outcome values. */
class NotifyException extends IllegalArgumentException {
NotifyException(String message) {
super(message);
}
}
// ---- templates ----
record Rendered(String subject, String body) {}
record Template(String subject, String body) {
private static String fill(String text, Map<String, String> vars) {
var out = new StringBuilder();
int i = 0;
while (i < text.length()) {
char c = text.charAt(i);
if (c != '{') {
out.append(c);
i++;
continue;
}
int close = text.indexOf('}', i + 1);
if (close < 0) {
throw new NotifyException("unclosed placeholder");
}
String name = text.substring(i + 1, close);
String value = vars.get(name);
if (value == null) {
throw new NotifyException("missing variable: " + name);
}
out.append(value);
i = close + 1;
}
return out.toString();
}
Rendered render(Map<String, String> vars) {
return new Rendered(fill(subject, vars), fill(body, vars));
}
}
// ---- users and preferences ----
/** An address per channel. A channel without an entry has no address. */
record Contact(Map<Channel, String> addresses) {
Contact {
addresses = Map.copyOf(addresses);
}
Optional<String> address(Channel channel) {
return Optional.ofNullable(addresses.get(channel));
}
}
/** Channels the user accepts per category. A category with no entry accepts every channel. */
record Preferences(Map<Category, Set<Channel>> accepted) {
Preferences {
accepted = Map.copyOf(accepted);
}
static Preferences acceptAll() {
return new Preferences(Map.of());
}
Preferences acceptOnly(Category category, Channel... channels) {
var copy = new HashMap<>(accepted);
copy.put(category, Set.copyOf(List.of(channels)));
return new Preferences(copy);
}
boolean allows(Category category, Channel channel) {
var set = accepted.get(category);
return set == null || set.contains(channel);
}
}
record User(Contact contact, Preferences preferences) {}
// ---- collaborators ----
interface Sender {
SendResult send(String to, Rendered message);
}
@FunctionalInterface
interface Sleeper {
void sleep(Duration duration);
}
/** Records the requested delays instead of waiting. */
class FakeSleeper implements Sleeper {
private final List<Long> slept = new ArrayList<>();
@Override
public synchronized void sleep(Duration duration) {
slept.add(duration.toMillis());
}
synchronized List<Long> sleptMillis() {
return List.copyOf(slept);
}
}
/** In-memory sender that fails as scripted, then succeeds. */
class FakeSender implements Sender {
private final Deque<SendError> failures;
private final List<String> delivered = new ArrayList<>();
FakeSender(List<SendError> failures) {
this.failures = new ArrayDeque<>(failures);
}
@Override
public synchronized SendResult send(String to, Rendered message) {
var failure = failures.poll();
if (failure != null) {
return failure;
}
delivered.add("%s <- %s | %s".formatted(to, message.subject(), message.body()));
return new SendResult.Sent();
}
synchronized List<String> delivered() {
return List.copyOf(delivered);
}
}
record RetryPolicy(int maxAttempts, Duration base, long factor, Duration cap) {
RetryPolicy {
if (maxAttempts < 1 || factor < 1 || base.isNegative() || cap.isNegative()) {
throw new IllegalArgumentException("invalid retry policy");
}
if (cap.toMillis() > Long.MAX_VALUE / factor) {
throw new IllegalArgumentException("cap times factor must fit in a long");
}
}
Duration delay(int failedAttempts) {
long ms = base.toMillis();
long capMs = cap.toMillis();
for (int i = 1; i < failedAttempts && ms < capMs; i++) {
ms = Math.min(capMs, ms * factor);
}
return Duration.ofMillis(Math.min(ms, capMs));
}
}
sealed interface DeliveryEvent {
record AttemptFailed(Channel channel, int attempt, SendError error) implements DeliveryEvent {}
record Finished(Channel channel, Outcome outcome) implements DeliveryEvent {}
}
@FunctionalInterface
interface DeliveryObserver {
void onEvent(DeliveryEvent event);
}
class LogObserver implements DeliveryObserver {
private final List<String> lines = new ArrayList<>();
@Override
public synchronized void onEvent(DeliveryEvent event) {
lines.add(event.toString());
}
synchronized List<String> lines() {
return List.copyOf(lines);
}
}
// ---- the service ----
class NotificationService {
private final Map<Channel, Sender> senders = new EnumMap<>(Channel.class);
private final Map<String, Template> templates = new HashMap<>();
private final Map<UserId, User> users = new HashMap<>();
private final List<DeliveryObserver> observers = new ArrayList<>();
private final RetryPolicy policy;
private final Sleeper sleeper;
NotificationService(RetryPolicy policy, Sleeper sleeper) {
this.policy = Objects.requireNonNull(policy);
this.sleeper = Objects.requireNonNull(sleeper);
}
void registerSender(Channel channel, Sender sender) {
senders.put(channel, Objects.requireNonNull(sender));
}
void addTemplate(String name, Template template) {
templates.put(name, Objects.requireNonNull(template));
}
void addUser(UserId id, User user) {
users.put(id, Objects.requireNonNull(user));
}
void subscribe(DeliveryObserver observer) {
observers.add(Objects.requireNonNull(observer));
}
private void emit(DeliveryEvent event) {
for (var observer : observers) {
observer.onEvent(event);
}
}
Map<Channel, Outcome> notify(UserId userId, Category category, String templateName, Map<String, String> vars) {
var user = users.get(userId);
if (user == null) {
throw new NotifyException("unknown user " + userId.id());
}
var template = templates.get(templateName);
if (template == null) {
throw new NotifyException("unknown template: " + templateName);
}
var message = template.render(vars); // fails before anything is sent
var report = new EnumMap<Channel, Outcome>(Channel.class);
for (var channel : Channel.values()) {
var outcome = dispatch(user, category, channel, message);
emit(new DeliveryEvent.Finished(channel, outcome));
report.put(channel, outcome);
}
return report;
}
private Outcome dispatch(User user, Category category, Channel channel, Rendered message) {
if (!category.mandatory() && !user.preferences().allows(category, channel)) {
return new Outcome.Skipped(SkipReason.OPTED_OUT);
}
var address = user.contact().address(channel);
if (address.isEmpty()) {
return new Outcome.Skipped(SkipReason.NO_ADDRESS);
}
var sender = senders.get(channel);
if (sender == null) {
return new Outcome.Skipped(SkipReason.NO_SENDER);
}
return deliver(channel, sender, address.get(), message);
}
private Outcome deliver(Channel channel, Sender sender, String to, Rendered message) {
for (int attempt = 1; ; attempt++) {
switch (sender.send(to, message)) {
case SendResult.Sent sent -> {
return new Outcome.Delivered(attempt);
}
case SendError.Transient failure -> {
emit(new DeliveryEvent.AttemptFailed(channel, attempt, failure));
if (attempt >= policy.maxAttempts()) {
return new Outcome.Failed(attempt, failure);
}
sleeper.sleep(policy.delay(attempt));
}
case SendError.Permanent failure -> {
emit(new DeliveryEvent.AttemptFailed(channel, attempt, failure));
return new Outcome.Failed(attempt, failure);
}
}
}
}
}
public class Main {
static void show(String title, java.util.function.Supplier<Map<Channel, Outcome>> call) {
System.out.println(title);
try {
call.get().forEach((channel, outcome) -> System.out.println(" " + channel + ": " + outcome));
} catch (NotifyException e) {
System.out.println(" error: " + e.getMessage());
}
}
public static void main(String[] args) {
var sleeper = new FakeSleeper();
var log = new LogObserver();
var email = new FakeSender(List.of(new SendError.Transient("smtp timeout"), new SendError.Transient("smtp timeout")));
var sms = new FakeSender(List.of(new SendError.Permanent("number blocked")));
var push = new FakeSender(List.of());
var policy = new RetryPolicy(4, Duration.ofMillis(100), 2, Duration.ofSeconds(1));
var service = new NotificationService(policy, sleeper);
service.registerSender(Channel.EMAIL, email);
service.registerSender(Channel.SMS, sms);
service.registerSender(Channel.PUSH, push);
service.subscribe(log);
service.addTemplate("shipped", new Template("Order {order} shipped", "Hi {name}, order {order} is on its way."));
service.addTemplate("reset", new Template("Password reset", "Use code {code} to reset your password."));
var alice = new UserId(1);
service.addUser(alice, new User(
new Contact(Map.of(Channel.EMAIL, "alice@example.com", Channel.SMS, "+1555000111", Channel.PUSH, "device-a")),
Preferences.acceptAll().acceptOnly(Category.MARKETING, Channel.PUSH)));
var bob = new UserId(2);
service.addUser(bob, new User(
new Contact(Map.of(Channel.EMAIL, "bob@example.com")),
Preferences.acceptAll().acceptOnly(Category.SECURITY).acceptOnly(Category.ORDERS)));
// Email retries twice and then succeeds, SMS fails permanently, push works first time.
var v = Map.of("order", "1042", "name", "Ada");
show("alice / orders", () -> service.notify(alice, Category.ORDERS, "shipped", v));
System.out.println(" backoff delays: " + sleeper.sleptMillis());
System.out.println(" observer saw " + log.lines().size() + " events");
// Marketing: alice accepts push only.
show("alice / marketing", () -> service.notify(alice, Category.MARKETING, "shipped", v));
// Security ignores bob's opt-outs, but he only has an email address.
show("bob / security", () -> service.notify(bob, Category.SECURITY, "reset", Map.of("code", "913")));
show("bob / orders", () -> service.notify(bob, Category.ORDERS, "shipped", v));
// Request errors are thrown before anything is sent.
show("alice / bad vars", () -> service.notify(alice, Category.ORDERS, "shipped", Map.of("order", "1")));
show("nobody", () -> service.notify(new UserId(9), Category.ORDERS, "shipped", v));
System.out.println("email delivered: " + email.delivered());
System.out.println("sms delivered: " + sms.delivered().size() + ", push delivered: " + push.delivered().size());
for (var line : log.lines().subList(0, 3)) {
System.out.println(line);
}
}
}alice / orders
EMAIL: Delivered[attempts=3]
SMS: Failed[attempts=1, error=Permanent[reason=number blocked]]
PUSH: Delivered[attempts=1]
backoff delays: [100, 200]
observer saw 6 events
alice / marketing
EMAIL: Skipped[reason=OPTED_OUT]
SMS: Skipped[reason=OPTED_OUT]
PUSH: Delivered[attempts=1]
bob / security
EMAIL: Delivered[attempts=1]
SMS: Skipped[reason=NO_ADDRESS]
PUSH: Skipped[reason=NO_ADDRESS]
bob / orders
EMAIL: Skipped[reason=OPTED_OUT]
SMS: Skipped[reason=OPTED_OUT]
PUSH: Skipped[reason=OPTED_OUT]
alice / bad vars
error: missing variable: name
nobody
error: unknown user 9
email delivered: [alice@example.com <- Order 1042 shipped | Hi Ada, order 1042 is on its way., bob@example.com <- Password reset | Use code 913 to reset your password.]
sms delivered: 0, push delivered: 2
AttemptFailed[channel=EMAIL, attempt=1, error=Transient[reason=smtp timeout]]
AttemptFailed[channel=EMAIL, attempt=2, error=Transient[reason=smtp timeout]]
Finished[channel=EMAIL, outcome=Delivered[attempts=3]]Read the output against the requirements. Email needs three attempts and the recorded delays are 100 and 200, the first two backoff steps. The permanent SMS failure stops after one attempt with no sleep. Marketing to Alice skips email and SMS as opted out and sends push. The security notice reaches Bob by email despite his opt-outs, and the other channels are skipped for lack of an address. A missing variable throws and sends nothing on any channel.
The design also shows a testing benefit: nothing in main waits, opens a socket or reads a clock, yet it exercises retries, preferences and observers end to end. See testability and dependency injection.
Extensions
| Follow-up | Approach |
|---|---|
| Asynchronous delivery | notify enqueues a job and returns immediately. Worker threads pull jobs and call deliver. See blocking queue and producer-consumer and, for the multi-machine version, design a notification system. |
| Parallel channels | Submit one task per channel to an ExecutorService (Executors.newVirtualThreadPerTaskExecutor() suits blocking provider calls), wait on the futures, and collect outcomes into the same EnumMap on the calling thread. Each sender must then be safe to call from several threads. |
| Jitter | Add a jitter field and inject a RandomGenerator so tests can pass a seeded one. |
| Rate limiting per provider | Wrap a Sender in a RateLimitedSender decorator using the design in rate limiter. Callers do not change. |
| Provider failover | A FailoverSender holding a list of senders and trying the next on SendError.Transient. It is itself a Sender. |
| Deduplication | Accept an idempotency key in notify and remember the outcome per key for a period. |
| Localization and channel-specific templates | Key templates by (name, Channel, Locale). SMS templates also need a length check. |
| Scheduling and quiet hours | Inject a Clock (see design a task scheduler) and defer non-mandatory sends until the user's quiet period ends. |
Interview points
- Use an enum for the closed set of channel kinds and an interface for the open behavior of sending. Register one
Senderper channel. - Separate transient from permanent send errors in the type. The retry loop retries only the first kind.
- Render the template once before any send, so a bad request fails cleanly and never half-sends.
- Retry is a
RetryPolicyvalue: capped exponential backoff, with the delay spent through aSleeper. - Delivery problems are values (
Outcome), request problems are exceptions.
Summary
- Use an enum for the closed set of channel kinds and an interface for the open behavior of sending. Register one
Senderper channel. - Separate transient from permanent send errors in a sealed type. The retry loop retries only the first kind, and a
switchwithoutdefaultguarantees every case is handled. - Render the template once before any send, so a bad request fails cleanly and never half-sends.
- Express retry as a
RetryPolicyrecord with capped exponential backoff. Spend the delay through aSleeperinterface so tests record it instead of waiting. - Resolve preferences in one place, with mandatory categories bypassing opt-outs, and report a per-channel
Outcomeincluding the skip reason. - Emit events to
DeliveryObservers so logging and metrics stay outside the dispatcher. - Fake senders, sleepers and observers make every behavior testable in-process with deterministic output.
