Problem 4.12 — Notification System (LLD)
In one line
The factory + strategy + observer showcase: channels, templates, fan-out and retries.
Think of it like this
The notifications you get from an app: sometimes an email, sometimes an SMS, sometimes a push alert. The system behind it decides which channel to use and what to say, then sends it and retries if it fails.
Key ideas
- 01
Channels: EMAIL, SMS, PUSH, IN-APP — each a ChannelStrategy with its own send() and failure semantics.
- 02
Templates: {userName} placeholders rendered against a template engine — keeps content consistent.
- 03
Delivery: best-effort with retries + dead-letter for undeliverable; per-channel backoff policies.
- 04
Fan-out: one event (order placed) → many users (customer, restaurant, admin) → Observer/EventBus.
- 05
Preference system: user can mute channels (opt-out rules evaluated before dispatch).
- 06
Batching & rate limits: don't hammer the SMS gateway; queue per channel.
- 07
Track per-notification status: QUEUED, SENT, FAILED, RETRYING, DLQ — feed the ops story.
Java / Spring map
- →
Spring: NotificationService(ChannelRegistry channels, TemplateEngine templates); @EventListener per channel consumer.
Code & diagrams
Strategy (channels) + Factory (registry) + Observer (event fan-out), the three patterns this problem is built to showcase.
public interface Channel {
String name();
void send(String to, String message) throws ChannelException;
}
@Component class EmailChannel implements Channel {
public String name() { return "EMAIL"; }
public void send(String to, String msg) { /* SMTP */ }
}
@Component class SmsChannel implements Channel {
public String name() { return "SMS"; }
public void send(String to, String msg) { /* SMS gateway */ }
}
@Service
public class NotificationService {
private final Map<String, Channel> channels; // built from all Channel beans — Factory/Registry
private final UserPreferences preferences;
public NotificationService(List<Channel> all, UserPreferences preferences) {
this.channels = all.stream().collect(Collectors.toMap(Channel::name, c -> c));
this.preferences = preferences;
}
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
public void notify(String userId, String channelName, String template, Map<String, String> vars) {
if (preferences.isMuted(userId, channelName)) return; // opt-out checked before dispatch
Channel channel = channels.get(channelName);
if (channel == null) throw new IllegalArgumentException("unknown channel: " + channelName);
String message = render(template, vars);
try {
channel.send(preferences.addressFor(userId, channelName), message);
} catch (ChannelException e) {
throw e; // @Retryable retries; after 3 tries → DLQ handler
}
}
private String render(String template, Map<String, String> vars) {
String out = template;
for (var e : vars.entrySet()) out = out.replace("{" + e.getKey() + "}", e.getValue());
return out;
}
}
// Fan-out (Observer): one event, many notifications, each independently retried and mutable.
@EventListener
public void onOrderPlaced(OrderPlacedEvent e) {
notificationService.notify(e.customerId(), "SMS", "order-confirmed", Map.of("id", e.orderId()));
notificationService.notify(e.restaurantId(), "PUSH", "new-order", Map.of("id", e.orderId()));
}Explain without notes
Order of decisions: who decides a user gets an SMS — the orchestrator or the channel? Justify.
Practice
Add an EMAIL channel with HTML template + retry-3-times-with-backoff; add a 'mute marketing' preference.
Trade-offs
- ↔
Sync sends (simple, slow) vs queue-backed async (fast, exactly-once-adjacent but complex). Choose async, defend it.
Run it in production
You've designed it. Now build, operate, and break the same idea hands-on in the DevOps courses:
Completion checklist
I can justify channel + template + preference + retry separation from first principles.