SDE Path

Walkthrough: Design a Notification Service

10 min read

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.

<!--STEP-->

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.

<!--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.

<!--STEP-->

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.

Check yourself

5 questions — graded when you submit.

Question 1

What does the Observer pattern give the notification design?

Question 2

Why does send loop over the NotificationChannel interface rather than concrete senders?

Question 3

What has to change to add a Slack channel?

Question 4

Why implement retry as a RetryChannel that wraps another channel?

Question 5

Why hand each channel's delivery to an executor or queue at scale?

Finished reading?

Sign in to save your progress across lessons.