The coupling problem
A checkout wants to notify the user "order shipped" over email, SMS, and push — and next quarter, Slack. If the order code calls each sender directly, every new channel edits the order code. The Observer pattern inverts that: the order publishes an event; channels subscribe.
interface NotificationChannel { // the observer
void notify(String userId, String message);
}
The goal: the thing raising an event knows nothing about who consumes it — publishers and subscribers evolve independently.
<!--STEP-->The subject holds its subscribers
class EmailChannel implements NotificationChannel {
public void notify(String userId, String msg) { /* send email */ }
}
class SmsChannel implements NotificationChannel {
public void notify(String userId, String msg) { /* send SMS */ }
}
class NotificationService { // the subject
private final List<NotificationChannel> channels = new CopyOnWriteArrayList<>();
void subscribe(NotificationChannel c) { channels.add(c); }
void unsubscribe(NotificationChannel c) { channels.remove(c); }
void send(String userId, String msg) {
for (NotificationChannel c : channels) c.notify(userId, msg);
}
}
send iterates an abstraction — NotificationChannel — never a concrete sender. Every channel is interchangeable behind that interface (Liskov / Dependency Inversion): the service depends on the contract, not on Email or SMS.
A new channel = a new class
Marketing wants Slack alerts. Watch what doesn't change:
class SlackChannel implements NotificationChannel {
public void notify(String userId, String msg) { /* post to Slack */ }
}
service.subscribe(new SlackChannel()); // that's the entire change
No edit to NotificationService, no edit to the checkout, no touch to Email or SMS. Adding behaviour by adding code rather than modifying existing code is the Open/Closed Principle — and it's only possible because of the Observer indirection from the previous step.
Don't block the caller; wrap for retry
Two real concerns: sending is slow (network) and can fail. Solve them without bloating channels. Add retry with a decorator that wraps any channel.
class RetryChannel implements NotificationChannel {
private final NotificationChannel inner;
private final int attempts;
RetryChannel(NotificationChannel inner, int attempts) { this.inner = inner; this.attempts = attempts; }
public void notify(String userId, String msg) {
for (int i = 0; i < attempts; i++) {
try { inner.notify(userId, msg); return; }
catch (RuntimeException e) { if (i == attempts - 1) throw e; }
}
}
}
service.subscribe(new RetryChannel(new SmsChannel(), 3));
RetryChannel is a NotificationChannel, so it slots in anywhere one is expected and composes with any sender. Retry logic lives in one place instead of being copy-pasted into Email, SMS, and Slack.
Sync vs async, and where a queue goes
For a few channels, in-process fan-out is fine. At scale, send should hand each delivery to a worker (or enqueue an event) so a slow SMS provider never delays the email, and failures retry off the hot path.
void send(String userId, String msg) {
for (NotificationChannel c : channels)
executor.submit(() -> c.notify(userId, msg)); // off the request thread
}
The pattern choices compound: Observer decouples who-listens, Decorator layers retry, and an executor/queue decouples when delivery happens. Each is a separate axis you can defend independently in an interview.
Practice it for real: the Notification Service problem in the ladder exercises subscribe/unsubscribe and multi-channel dispatch against hidden tests.