Low Level Design
Design a Notification Service
A multi-channel notification broker with per-service subscription control, parallel fan-out via an ExecutorService, exponential-backoff retry, and a Dead Letter Queue — built around Template Method, Strategy, Builder, and Singleton.
Problem Description#
Design a Notification Service that acts as a central broker — backend services publish notification requests, and the system fans them out to subscribed clients through whichever channels (Email, SMS, Push) each client has opted into.
The interesting complexity here is the two-layer permission model: a backend service declares which channels it is allowed to use, and a client independently subscribes to a subset of those channels. Delivery only happens through channels that satisfy both constraints. Add parallel fan-out, retry logic with exponential backoff, and a Dead Letter Queue for undeliverable messages — and you have a system that touches concurrency, extensibility, and fault tolerance all at once.
This problem is rich in design patterns: Singleton (single entry point), Template Method (fixed delivery sequence), Strategy (pluggable channels), Builder (request construction), and Factory (object creation with UUID generation).
Clarify Requirements#
Before designing, ask these questions in an interview:
Functional
- What channels must be supported initially? Can new channels be added without touching existing code?
- Who controls which channels a service may use — the service itself or a central admin?
- Can a client subscribe to a channel the service doesn't allow?
- Should notifications be delivered synchronously or asynchronously?
- What happens when a delivery fails — retry? How many times? With what delay?
- Where do permanently-failed messages go?
Non-functional
- How many concurrent notification requests should the system handle?
- Is thread safety required for subscription management?
- Should the DLQ be in-memory or persisted?
- Is a single NotificationSystem instance shared across the whole application?
Final Requirements#
After clarification, here's what we'll build:
- NotificationSystem Singleton — single entry point for registration, subscription, and sending
- Services declare an allow-list of channels; subscribing a client to a channel not on the allow-list throws immediately
- Subscriptions are stored as Map<serviceId, Map<clientId, List<Channel>>> in SubscriptionRepository
- NotificationService fans out each request to all (client, channel) pairs in parallel via ExecutorService
- Each channel uses the Template Method pattern: validate → formatMessage → send → logResult; subclasses override only formatting and delivery
- Failed deliveries are retried up to 3 times with exponential backoff (1 s → 2 s → 4 s)
- Requests that exhaust all retries land in a Dead Letter Queue (ConcurrentLinkedQueue) for audit
- ClientFactory and ServiceFactory generate UUIDs and construct domain objects — callers never call new Client() directly
Core Entities#
| Entity | Responsibility |
|---|---|
| Client | Immutable value object — id, name, email, phone number, device token |
| Service | Identifies a backend service and its allow-listed channel types |
| NotificationRequest | Immutable request built via Builder — id, serviceId, message, timestamp |
| NotificationChannel | Abstract base — defines the fixed delivery sequence via Template Method |
| EmailNotificationChannel | HTML-wraps the message; sends via email gateway |
| SMSNotificationChannel | Truncates to 160 chars; sends via SMS gateway |
| PushNotificationChannel | Truncates to 100 chars; sends via push provider |
| SubscriptionRepository | Stores subscriptions keyed by (serviceId, clientId) → List<ChannelType> |
| ClientInventory | Thread-safe CRUD store for Client objects |
| ServiceInventory | Thread-safe CRUD store for Service objects |
| NotificationService | Fan-out engine — parallel delivery, retry with backoff, DLQ |
| NotificationSystem | Singleton entry point — wires all components, enforces allow-list at subscribe time |
| ClientFactory | Static factory: creates Client with a generated UUID |
| ServiceFactory | Static factory: creates Service with a generated UUID |
Patterns Used#
1. Singleton — NotificationSystem#
The entire application shares one NotificationSystem instance. It owns all four sub-components (ClientInventory, ServiceInventory, SubscriptionRepository, NotificationService) and is the only public surface area callers ever touch. Lazy initialisation is guarded with synchronized to be thread-safe.
2. Template Method — NotificationChannel#
NotificationChannel defines a fixed, final delivery sequence:
sendNotification(request, client)
1. validate(request, client) ← common; subclasses may override
2. formatMessage(request, client) ← abstract; each channel implements
3. send(formatted, client) ← abstract; each channel implements
4. logResult(client, formatted) ← common; subclasses may override
The method is final so no subclass can reorder the steps. formatMessage and send are abstract, forcing each channel to provide its own version. Adding a Slack channel means subclassing NotificationChannel and implementing two methods — nothing else changes.
3. Strategy — concrete channel implementations#
Each channel class (EmailNotificationChannel, SMSNotificationChannel, PushNotificationChannel) is an interchangeable strategy. NotificationService refers to channels only by their Class<? extends NotificationChannel> type, instantiating them via reflection at delivery time. This means the set of available channels is open for extension without modifying the service.
4. Builder — NotificationRequest#
NotificationRequest has an auto-generated UUID and a defaultable timestamp — messy to pass as a long constructor argument list. The nested Builder lets callers set only what they care about:
new NotificationRequest.Builder()
.setServiceId(service1.getId())
.setMessage("It's going to rain today!")
.build();
5. Factory — ClientFactory and ServiceFactory#
Both factories hide UUID generation and constructor calls. Callers never deal with IDs — the factory produces a fully formed Client or Service with a guaranteed-unique identifier.
Code#
Models — Client, Service, NotificationRequest#
Client and Service are immutable value objects. NotificationRequest is built via the Builder pattern with an auto-generated UUID and optional timestamp.
public class Client {
private final String id;
private final String name;
private final String email;
private final String phoneNumber;
private final String deviceToken;
public Client(String id, String name, String email, String phoneNumber, String deviceToken) {
this.id = id;
this.name = name;
this.email = email;
this.phoneNumber = phoneNumber;
this.deviceToken = deviceToken;
}
public String getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
public String getPhoneNumber() { return phoneNumber; }
public String getDeviceToken() { return deviceToken; }
}import java.util.List;
public class Service {
private final String id;
private final String name;
private final List<Class<? extends NotificationChannel>> allowedChannels;
public Service(String id, String name, List<Class<? extends NotificationChannel>> allowedChannels) {
this.id = id;
this.name = name;
this.allowedChannels = allowedChannels;
}
public String getId() { return id; }
public String getName() { return name; }
public List<Class<? extends NotificationChannel>> getAllowedChannels() { return allowedChannels; }
}import java.time.LocalDateTime;
import java.util.UUID;
public class NotificationRequest {
private final String id;
private final String serviceId;
private final String message;
private final LocalDateTime timestamp;
private NotificationRequest(Builder builder) {
this.id = UUID.randomUUID().toString();
this.serviceId = builder.serviceId;
this.message = builder.message;
this.timestamp = builder.timestamp != null ? builder.timestamp : LocalDateTime.now();
}
public String getId() { return id; }
public String getServiceId() { return serviceId; }
public String getMessage() { return message; }
public LocalDateTime getTimestamp() { return timestamp; }
public static class Builder {
private String serviceId;
private String message;
private LocalDateTime timestamp;
public Builder setServiceId(String serviceId) { this.serviceId = serviceId; return this; }
public Builder setMessage(String message) { this.message = message; return this; }
public Builder setTimestamp(LocalDateTime timestamp){ this.timestamp = timestamp; return this; }
public NotificationRequest build() {
return new NotificationRequest(this);
}
}
}Factories — ClientFactory, ServiceFactory#
import java.util.UUID;
public class ClientFactory {
public static Client createClient(String name, String email,
String phoneNumber, String deviceToken) {
return new Client(UUID.randomUUID().toString(), name, email, phoneNumber, deviceToken);
}
}import java.util.List;
import java.util.UUID;
public class ServiceFactory {
public static Service createService(String name,
List<Class<? extends NotificationChannel>> allowedChannels) {
return new Service(UUID.randomUUID().toString(), name, allowedChannels);
}
}Repositories — ClientInventory, ServiceInventory, SubscriptionRepository#
ClientInventory and ServiceInventory are thread-safe CRUD stores backed by ConcurrentHashMap. SubscriptionRepository is the heart of the data model — it stores which channels each client subscribes to per service, and enforces the lookup contract used by the delivery engine.
import java.util.Collection;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
public class ClientInventory {
private final Map<String, Client> clients = new ConcurrentHashMap<>();
public void addClient(Client client) { clients.put(client.getId(), client); }
public Optional<Client> getClient(String id) { return Optional.ofNullable(clients.get(id)); }
public Collection<Client> getAllClients() { return clients.values(); }
public void removeClient(String clientId) { clients.remove(clientId); }
}import java.util.Collection;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
public class ServiceInventory {
private final Map<String, Service> services = new ConcurrentHashMap<>();
public void addService(Service service) { services.put(service.getId(), service); }
public Optional<Service> getService(String id) { return Optional.ofNullable(services.get(id)); }
public Collection<Service> getAllServices() { return services.values(); }
public void removeService(String serviceId) { services.remove(serviceId); }
}import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class SubscriptionRepository {
// Map<serviceId, Map<clientId, List<channelType>>>
private final Map<String, Map<String, List<Class<? extends NotificationChannel>>>> subscriptions
= new ConcurrentHashMap<>();
// clientId → Client (for lookup during fan-out)
private final Map<String, Client> clientRegistry = new ConcurrentHashMap<>();
public synchronized void subscribe(Service service, Client client,
Class<? extends NotificationChannel> channel) {
clientRegistry.put(client.getId(), client);
subscriptions
.computeIfAbsent(service.getId(), k -> new ConcurrentHashMap<>())
.computeIfAbsent(client.getId(), k -> new ArrayList<>())
.add(channel);
}
public void unsubscribe(Service service, Client client,
Class<? extends NotificationChannel> channel) {
Map<String, List<Class<? extends NotificationChannel>>> clientMap =
subscriptions.get(service.getId());
if (clientMap != null) {
List<Class<? extends NotificationChannel>> channels = clientMap.get(client.getId());
if (channels != null) channels.remove(channel);
}
}
public List<Client> getSubscribedClients(Service service) {
Map<String, List<Class<? extends NotificationChannel>>> clientMap =
subscriptions.getOrDefault(service.getId(), Collections.emptyMap());
List<Client> clients = new ArrayList<>();
for (String clientId : clientMap.keySet()) {
Client c = clientRegistry.get(clientId);
if (c != null) clients.add(c);
}
return clients;
}
public List<Class<? extends NotificationChannel>> getSubscribedChannels(
Service service, Client client) {
return subscriptions
.getOrDefault(service.getId(), Collections.emptyMap())
.getOrDefault(client.getId(), Collections.emptyList());
}
}Channels — Template Method + Strategy#
NotificationChannel is the abstract base. Its sendNotification method is final — the sequence is fixed. Concrete channels only override formatMessage and send.
public abstract class NotificationChannel {
// Template method — fixed sequence, cannot be overridden
public final void sendNotification(NotificationRequest request, Client client) {
if (!validate(request, client)) {
System.out.println("Validation failed for request: " + request.getId());
return;
}
String formatted = formatMessage(request, client);
send(formatted, client);
logResult(client, formatted);
}
protected boolean validate(NotificationRequest request, Client client) {
return request != null
&& request.getMessage() != null
&& !request.getMessage().trim().isEmpty()
&& client != null;
}
protected abstract String formatMessage(NotificationRequest request, Client client);
protected abstract void send(String formattedMessage, Client client);
protected void logResult(Client client, String formattedMessage) {
System.out.printf("[%s] ✓ Delivered to %s | Message: %s%n",
this.getClass().getSimpleName(), client.getName(), formattedMessage);
}
}public class EmailNotificationChannel extends NotificationChannel {
@Override
protected String formatMessage(NotificationRequest request, Client client) {
return "<html><body><p>" + request.getMessage() + "</p></body></html>";
}
@Override
protected void send(String formattedMessage, Client client) {
// Integrate with email gateway (e.g. SendGrid, AWS SES)
System.out.println("[Email] Sending to " + client.getEmail() + ": " + formattedMessage);
}
}public class SMSNotificationChannel extends NotificationChannel {
@Override
protected String formatMessage(NotificationRequest request, Client client) {
String message = request.getMessage();
if (message.length() > 160) message = message.substring(0, 157) + "...";
return message;
}
@Override
protected void send(String formattedMessage, Client client) {
// Integrate with SMS gateway (e.g. Twilio)
System.out.println("[SMS] Sending to " + client.getPhoneNumber() + ": " + formattedMessage);
}
}public class PushNotificationChannel extends NotificationChannel {
@Override
protected String formatMessage(NotificationRequest request, Client client) {
String message = request.getMessage();
if (message.length() > 100) message = message.substring(0, 97) + "...";
return message;
}
@Override
protected void send(String formattedMessage, Client client) {
// Integrate with push provider (e.g. Firebase FCM, Apple APNs)
System.out.println("[Push] Sending to token " + client.getDeviceToken() + ": " + formattedMessage);
}
}Delivery engine and entry point#
NotificationService fans out to all (client, channel) pairs in parallel, retries on failure with exponential backoff, and routes exhausted requests to the DLQ. NotificationSystem is the Singleton that wires everything together and enforces the channel allow-list at subscription time.
import java.util.*;
import java.util.concurrent.*;
public class NotificationService {
private static final int MAX_RETRIES = 3;
private static final long INITIAL_BACKOFF_MS = 1000;
private final SubscriptionRepository subscriptionRepository;
private final ExecutorService executor;
private final Queue<NotificationRequest> dlq = new ConcurrentLinkedQueue<>();
public NotificationService(SubscriptionRepository subscriptionRepository) {
this.subscriptionRepository = subscriptionRepository;
this.executor = Executors.newCachedThreadPool();
}
public void processNotification(NotificationRequest request, Service service) {
List<Client> clients = subscriptionRepository.getSubscribedClients(service);
List<Future<?>> futures = new ArrayList<>();
for (Client client : clients) {
List<Class<? extends NotificationChannel>> channels =
subscriptionRepository.getSubscribedChannels(service, client);
for (Class<? extends NotificationChannel> channelClass : channels) {
futures.add(executor.submit(() -> sendWithRetry(request, client, channelClass)));
}
}
for (Future<?> future : futures) {
try {
future.get(); // wait for each delivery to complete
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
System.out.println("Delivery error: " + e.getCause().getMessage());
}
}
}
private void sendWithRetry(NotificationRequest request, Client client,
Class<? extends NotificationChannel> channelClass) {
int attempts = 0;
long backoff = INITIAL_BACKOFF_MS;
while (attempts < MAX_RETRIES) {
try {
NotificationChannel channel = channelClass.getDeclaredConstructor().newInstance();
channel.sendNotification(request, client);
return; // success — exit retry loop
} catch (Exception e) {
attempts++;
System.out.printf("Attempt %d failed for %s → %s: %s%n",
attempts, channelClass.getSimpleName(), client.getName(), e.getMessage());
if (attempts < MAX_RETRIES) {
try { Thread.sleep(backoff); } catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
backoff *= 2; // exponential backoff: 1s → 2s → 4s
}
}
}
addToDLQ(request);
}
private void addToDLQ(NotificationRequest request) {
System.out.println("All retries exhausted. Moving to DLQ: " + request.getId());
dlq.add(request);
}
public Queue<NotificationRequest> getDLQ() { return dlq; }
public void shutdown() { executor.shutdown(); }
}public class NotificationSystem {
private static NotificationSystem instance;
private final ClientInventory clientInventory;
private final ServiceInventory serviceInventory;
private final SubscriptionRepository subscriptionRepository;
private final NotificationService notificationService;
private NotificationSystem() {
this.clientInventory = new ClientInventory();
this.serviceInventory = new ServiceInventory();
this.subscriptionRepository = new SubscriptionRepository();
this.notificationService = new NotificationService(subscriptionRepository);
}
public static synchronized NotificationSystem getInstance() {
if (instance == null) instance = new NotificationSystem();
return instance;
}
public void registerClient(Client client) { clientInventory.addClient(client); }
public void registerService(Service service){ serviceInventory.addService(service); }
/** Subscribes client to a channel — throws if the service doesn't allow that channel. */
public void subscribe(Service service, Client client,
Class<? extends NotificationChannel> channel) {
if (!service.getAllowedChannels().contains(channel)) {
throw new IllegalArgumentException(
"Channel " + channel.getSimpleName()
+ " is not allowed by service " + service.getName());
}
subscriptionRepository.subscribe(service, client, channel);
}
public void unsubscribe(Service service, Client client,
Class<? extends NotificationChannel> channel) {
subscriptionRepository.unsubscribe(service, client, channel);
}
public void sendNotification(NotificationRequest request) {
Service service = serviceInventory.getService(request.getServiceId())
.orElseThrow(() -> new IllegalArgumentException(
"Unknown service: " + request.getServiceId()));
notificationService.processNotification(request, service);
}
}Demo#
import java.util.List;
public class NotificationSystemDemo {
public static void main(String[] args) {
// Create clients and services via factories (UUIDs are auto-generated)
Client client1 = ClientFactory.createClient("Alice", "alice@gmail.com", "1234567890", "tokenAlice");
Client client2 = ClientFactory.createClient("Ben", "ben@gmail.com", "1234237890", "tokenBen");
Service weatherService = ServiceFactory.createService(
"Weather Updates", List.of(EmailNotificationChannel.class, SMSNotificationChannel.class));
Service stockService = ServiceFactory.createService(
"Stock Alerts", List.of(EmailNotificationChannel.class, PushNotificationChannel.class));
NotificationSystem system = NotificationSystem.getInstance();
system.registerClient(client1);
system.registerClient(client2);
system.registerService(weatherService);
system.registerService(stockService);
// Alice subscribes to Weather via Email + SMS
system.subscribe(weatherService, client1, EmailNotificationChannel.class);
system.subscribe(weatherService, client1, SMSNotificationChannel.class);
// Ben subscribes to Weather via Email, Stock Alerts via Push
system.subscribe(weatherService, client2, EmailNotificationChannel.class);
system.subscribe(stockService, client2, PushNotificationChannel.class);
// This would throw — Push is not in weatherService.allowedChannels
// system.subscribe(weatherService, client1, PushNotificationChannel.class);
NotificationRequest weatherAlert = new NotificationRequest.Builder()
.setServiceId(weatherService.getId())
.setMessage("It's going to rain today!")
.build();
NotificationRequest stockAlert = new NotificationRequest.Builder()
.setServiceId(stockService.getId())
.setMessage("AAPL stock price just hit $150!")
.build();
system.sendNotification(weatherAlert); // Alice (Email+SMS), Ben (Email)
system.sendNotification(stockAlert); // Ben (Push)
}
}
Class Diagram#
Extendible — Follow Ups#
1. Add a new channel without touching any existing class#
Create SlackNotificationChannel extends NotificationChannel, override formatMessage (build a Slack-flavoured JSON payload) and send (call the Slack webhook). Register the class in any service's allowedChannels list. Zero changes to NotificationSystem, NotificationService, or existing channels — this is Open/Closed in action.
2. Persistent Dead Letter Queue with replay#
Replace the in-memory ConcurrentLinkedQueue<NotificationRequest> with a DLQRepository interface backed by a database or message queue (Kafka, SQS). Add a replayDLQ() method to NotificationService that reads unacknowledged entries and re-submits them through the normal delivery path.
3. Priority notifications#
Add a priority field to NotificationRequest (HIGH / NORMAL / LOW). Replace Executors.newCachedThreadPool() with a PriorityBlockingQueue-backed ThreadPoolExecutor that sorts submitted tasks by priority. HIGH-priority notifications jump ahead of queued NORMAL ones.
4. Channel-level preference per client#
Allow a client to set preferred time windows or opt-out of a channel entirely via a ClientChannelPreference record. NotificationService.processNotification checks preferences before submitting each (client, channel) task — no delivery happens outside the client's declared window.
5. Metrics and observability#
Wrap NotificationService with a decorator that increments Prometheus counters on every delivered, retried, and dlq event, tagged by channel type and service ID. The decorator implements the same processNotification interface, so it can be swapped in transparently.
6. Asynchronous request acceptance#
Change sendNotification to push NotificationRequest objects into a BlockingQueue. A pool of worker threads drains the queue and calls processNotification. The caller returns immediately after enqueuing — delivery is fire-and-forget — which is the model used by Kafka producers and most real notification pipelines.