Build a multi-channel notification system with pluggable channel handlers, template support, retry logic via Chain of Responsibility, and event-driven dispatch.
Overview
A Notification System delivers messages across EMAIL, SMS, PUSH, and IN_APP channels. NotificationService receives a NotificationRequest, resolves the appropriate ChannelHandler implementations via Strategy, and dispatches through a Chain of Responsibility that adds retry and fallback behaviour. NotificationTemplate externalises message content so channel implementations stay free of business copy. Observer (event-driven) triggers notifications from application events without the caller needing to know the NotificationService API. This design is commonly asked at companies building platform-level services.
Requirements Analysis
Functional: send notification to one or more channels, support message templates, retry on transient failure, fall back to next channel when primary fails, track delivery status. Non-functional: adding a new channel requires only a new ChannelHandler class (OCP), retry logic isolated in the chain and not duplicated across handlers.
// Entities : NotificationService, NotificationRequest, NotificationChannel, ChannelHandler, NotificationTemplate
// Patterns : Strategy (channel selection), Chain of Responsibility (retry/fallback), Observer (event-driven trigger)Core Classes & Relationships
NotificationChannel enum: EMAIL, SMS, PUSH, IN_APP. ChannelHandler interface has send(NotificationRequest). Abstract RetryHandler wraps a ChannelHandler and retries on failure up to maxAttempts. NotificationTemplate holds subject and body with placeholder substitution. NotificationRequest carries recipient, channels list, template id, and dynamic variables.
public enum NotificationChannel { EMAIL, SMS, PUSH, IN_APP }
public interface ChannelHandler {
boolean send(NotificationRequest request);
NotificationChannel channel();
}
public class NotificationRequest {
private final String recipientId;
private final String recipientAddress; // email, phone, deviceToken
private final List<NotificationChannel> channels;
private final String templateId;
private final Map<String, String> variables;
public NotificationRequest(String recipientId, String recipientAddress,
List<NotificationChannel> channels,
String templateId, Map<String, String> variables) {
this.recipientId = recipientId; this.recipientAddress = recipientAddress;
this.channels = channels; this.templateId = templateId; this.variables = variables;
}
public List<NotificationChannel> getChannels() { return channels; }
public String getRecipientAddress() { return recipientAddress; }
public Map<String, String> getVariables() { return variables; }
public String getTemplateId() { return templateId; }
}
public class NotificationTemplate {
private final String subject;
private final String bodyTemplate;
public NotificationTemplate(String subject, String bodyTemplate) {
this.subject = subject; this.bodyTemplate = bodyTemplate;
}
public String render(Map<String, String> vars) {
String body = bodyTemplate;
for (Map.Entry<String, String> e : vars.entrySet()) {
body = body.replace("{{" + e.getKey() + "}}", e.getValue());
}
return body;
}
public String getSubject() { return subject; }
}Java Implementation
EmailHandler and SmsHandler are concrete ChannelHandlers. RetryChannelHandler wraps any ChannelHandler and retries up to maxAttempts on failure. NotificationService resolves the template, selects registered handlers for the requested channels, and dispatches each through its retry wrapper. A FallbackChain tries channels in order until one succeeds.
public class EmailHandler implements ChannelHandler {
@Override public NotificationChannel channel() { return NotificationChannel.EMAIL; }
@Override public boolean send(NotificationRequest req) {
System.out.println("[EMAIL] Sending to " + req.getRecipientAddress());
// integrate with SendGrid/SES here
return true;
}
}
public class SmsHandler implements ChannelHandler {
@Override public NotificationChannel channel() { return NotificationChannel.SMS; }
@Override public boolean send(NotificationRequest req) {
System.out.println("[SMS] Sending to " + req.getRecipientAddress());
// integrate with Twilio here
return true;
}
}
public class PushHandler implements ChannelHandler {
@Override public NotificationChannel channel() { return NotificationChannel.PUSH; }
@Override public boolean send(NotificationRequest req) {
System.out.println("[PUSH] Sending to device " + req.getRecipientAddress());
return true;
}
}
// Retry decorator — wraps any ChannelHandler
public class RetryChannelHandler implements ChannelHandler {
private final ChannelHandler delegate;
private final int maxAttempts;
public RetryChannelHandler(ChannelHandler delegate, int maxAttempts) {
this.delegate = delegate; this.maxAttempts = maxAttempts;
}
@Override public NotificationChannel channel() { return delegate.channel(); }
@Override public boolean send(NotificationRequest request) {
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
if (delegate.send(request)) return true;
} catch (Exception e) {
System.out.println("Attempt " + attempt + " failed: " + e.getMessage());
}
}
System.out.println("[RETRY] All " + maxAttempts + " attempts failed for " + channel());
return false;
}
}
// NotificationService — Facade + Strategy for channel selection
public class NotificationService {
private final Map<NotificationChannel, ChannelHandler> handlers = new EnumMap<>(NotificationChannel.class);
private final Map<String, NotificationTemplate> templates = new HashMap<>();
public void registerHandler(ChannelHandler handler, int retries) {
handlers.put(handler.channel(), new RetryChannelHandler(handler, retries));
}
public void registerTemplate(String id, NotificationTemplate template) {
templates.put(id, template);
}
public void send(NotificationRequest request) {
NotificationTemplate template = templates.get(request.getTemplateId());
if (template != null) {
String body = template.render(request.getVariables());
System.out.println("Message: " + body);
}
for (NotificationChannel ch : request.getChannels()) {
ChannelHandler handler = handlers.get(ch);
if (handler != null) {
boolean sent = handler.send(request);
System.out.printf("Channel %s: %s%n", ch, sent ? "SUCCESS" : "FAILED");
}
}
}
}Key Points to Remember
- 1ChannelHandler as an interface makes adding WhatsApp or Slack a new class with zero changes to NotificationService.
- 2RetryChannelHandler is a Decorator that wraps any handler transparently — retry logic is not duplicated across channel implementations.
- 3Template-based messaging decouples notification content from delivery infrastructure, enabling marketing to update copy without code changes.
- 4Observer-driven triggering (ApplicationEvent → NotificationService) ensures business code never directly instantiates notification requests.
Interview Questions
Sign in to ask AriaHow would you implement user notification preferences so each user can opt out of specific channels?
How would you design a rate limiter per recipient to prevent notification spam?
How would you track delivery receipts and retry undelivered notifications after a delay?
Ask Aria about Design a Notification System
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.