Problem 05 · Complete walkthrough•Intermediate•50 min

Notification System

Prefer to learn one decision at a time?

Try questions, reveal the reasoning, trace the code, and finish with the simulator.

Start Focus Mode

Design an in-memory notification system that accepts EMAIL, SMS, and PUSH messages, returns immediately, delivers through a fixed worker pool, retries expected provider failures, and lets callers inspect progress. The difficult part is not drawing three sender classes. It is defining exactly what acceptance means, protecting each job's changing state, and stating honestly what one Java process can guarantee.

A caller submits email, SMS, and push notifications while a temporary SMS failure is retried
Notice that the caller starts several deliveries without waiting. One channel can retry while unrelated messages continue.

1. Requirements (~5 minutes)

Use these four question groups every time

1
Actions

What must a user be able to do?

2
Rules

When does an action succeed, fail, or change state?

3
Errors

Which invalid actions must we reject?

4
Boundaries

What should we deliberately not build?

Ask these questions

AskWhen submit returns successfully, has the message been delivered?

Interviewer answer

No. Return a job ID as soon as valid work is accepted by the in-memory executor.

AskWhat content and channels are required?

Interviewer answer

Support EMAIL, SMS, and PUSH. Require recipient ID, channel-specific destination, and body. Subject is optional.

AskHow should failure and retry work?

Interviewer answer

Try at most three times in total. The first version retries immediately.

AskWhat may callers observe?

Interviewer answer

Return QUEUED, SENDING, SENT, or FAILED plus attempts and the latest error.

AskWhat concurrency and durability should we promise?

Interviewer answer

Use one fixed thread pool in one process. Persistent queues, distributed workers, and exactly-once delivery are outside scope.

Confirmed specification

8 requirements · 7 boundaries

The first version must

  1. 1.Support EMAIL, SMS, and PUSH through replaceable senders.
  2. 2.A validated immutable request contains recipient ID, destination, one channel, optional subject, and mandatory body.
  3. 3.Submit indexes a QUEUED job and immediately returns its ID.
  4. 4.A fixed ExecutorService runs separate jobs in parallel.
  5. 5.Provider failures receive no more than three total immediate attempts.
  6. 6.Callers can query an immutable status snapshot.
  7. 7.Shutdown rejects new work and lets accepted executor tasks finish.
  8. 8.No channel-specific branch belongs in NotificationService.
Not building in this version7 boundaries
  • Persistence or message broker
  • Distributed workers
  • Exactly-once delivery
  • Delayed backoff and jitter
  • Templates and localization
  • Preferences and fallback
  • Real provider SDKs

2. Entities and relationships (~3 minutes)

Start with nouns, then ask what each must remember or enforce. A queue and thread pool are real implementation concepts, but they do not automatically become domain classes.

Final model

Types we will create

Class
Owns changing state or coordinates rules.
Record
A Java record for validated, immutable data with value equality.
Interface
Defines behavior that another implementation can replace.
NotificationRequestRecord

Preserves the validated content accepted for delivery.

DeliveryJobClass

Owns one job's status, attempts, and latest failure.

NotificationSenderInterface

Defines one replaceable channel delivery attempt.

EmailSenderClass

Owns EMAIL provider-specific delivery behavior.

SmsSenderClass

Owns SMS provider-specific delivery behavior.

PushSenderClass

Owns PUSH provider-specific delivery behavior.

RetryPolicyRecord

Names and validates the total-attempt boundary.

NotificationServiceClass

Coordinates submission, indexing, workers, and lifecycle.

SubmissionReceiptRecord

Returns the accepted job ID immediately.

DeliverySnapshotRecord

Exposes progress without leaking the mutable job.

Enums

Enums name a small, fixed set of valid choices or outcomes.

NotificationChannel

Limits delivery to EMAIL, SMS, or PUSH.

DeliveryStatus

Names the lifecycle of one asynchronous job.

Fields

Fields are simple values stored inside another type; they do not need independent behavior in this version.

jobId

Lets callers query one accepted delivery job.

recipientId

Identifies the notification recipient.

destination

Stores the email address, phone number, or device target.

subject

Carries optional channel content.

body

Carries the mandatory message content.

attempts

Counts completed delivery attempts.

lastError

Preserves the latest provider failure for status queries.

Supporting implementation

ExecutorService

Runs accepted delivery jobs on worker threads.

worker queue

Holds in-memory tasks waiting for a worker.

Swipe to follow the flow →

A caller submits to NotificationService, which records a DeliveryJob and uses a thread pool and channel sender
Read left to right: acceptance creates visible job state before background delivery begins; retries update the same job.

The caller enters through NotificationService.submit. The service creates a DeliveryJob, stores it, and schedules one worker task. That worker finds the NotificationSender, performs bounded attempts, and asks the job to publish SENT or FAILED. The executor enables parallel work but does not own any delivery rule.

3. Class design (~15 minutes)

Use the chain requirement → responsible object → state → behavior → public API → relationships → principles → patterns. Begin at NotificationService.submit, then follow its dependencies downward.

One class at a time

Design the state owners

Start with the coordinator, then follow the request into the classes that protect changing state. Select a class to see only the fields and behavior needed for its responsibility.

Class 1 of 2

NotificationService

Accepts jobs immediately, schedules workers, selects senders, and owns service lifecycle.

Requirement behind this class

Submission returns a job ID before delivery, jobs run in parallel, failures retry, status is queryable, and shutdown rejects new work.

State it remembers

Private information required to perform this class's job.

Map<NotificationChannel, NotificationSender> senders

Selects channel behavior without service branches.

ConcurrentHashMap<String, DeliveryJob> jobs

Publishes accepted jobs safely to querying callers.

ExecutorService executor

Runs one worker task per accepted job.

RetryPolicy retryPolicy

Defines the total-attempt boundary.

AtomicLong jobSequence

Allocates unique IDs across concurrent submissions.

AtomicBoolean accepting

Prevents submission after shutdown begins across concurrent callers.

Behavior it owns

Public use cases and internal rules kept beside that state.

submit(request)Public API

Validates, indexes QUEUED state, submits work, and returns the receipt.

getStatus(jobId)Public API

Returns an immutable snapshot or an empty result.

close()Public API

Stops new submissions and starts graceful executor shutdown.

deliver(job)Internal

Runs attempts, delegates to the sender, and records terminal state.

Invariant it protects
Every returned receipt names an indexed job, and closed services accept no new work.
Works with
DeliveryJob, NotificationSender registry, RetryPolicy, and ExecutorService.
Does not own
Provider-specific delivery code or mutable per-job transition details.
Principle demonstrated
Dependency inversion and composition separate workflow from channel behavior and infrastructure.

Supporting types

These types carry stable data or define replaceable behavior. They support the state owners without becoming another place to store live workflow state.

NotificationRequest, SubmissionReceipt, DeliverySnapshot, RetryPolicyRecord

Carry validated immutable request, receipt, snapshot, and attempt-policy data.

Stable values can cross caller and worker threads without exposing mutable DeliveryJob state.

NotificationSenderInterface

Defines channel() and one provider delivery attempt.

The service registry depends on this contract and contains no EMAIL, SMS, or PUSH branches.

EmailSender, SmsSender, PushSenderImplementation

Own provider-specific delivery for one channel each.

They do not retry, index jobs, or change service lifecycle.

DeliveryExceptionHelper

Represents an expected provider-attempt failure.

The worker catches it and lets RetryPolicy decide whether another attempt is allowed.

The explorer separates service orchestration from per-job state and channel behavior. The next section concentrates on the asynchronous boundary those classes must protect together.

The asynchronous correctness boundary

A method call such as sender.send() is synchronous inside one worker: that worker waits for the provider attempt. The public workflow is asynchronous because submit() schedules the worker and returns first. This distinction prevents a common interview mistake—calling a method “async” merely because a thread pool exists while still waiting for its result.

The service must index the job before scheduling it. Otherwise a fast worker could finish before jobs.put, producing a moment where the caller has an ID but status lookup cannot find the completed work. If the executor rejects scheduling, remove the indexed job and fail submission; do not leave unreachable QUEUED work.

Each accepted job has one worker task, so two workers never intentionally deliver the same job in this model. Status readers still run concurrently with that worker. Synchronized job methods make each multi-field transition and snapshot visible as one consistent state.

Use: Strategy

The confirmed requirement has three versions of one delivery operation. NotificationSender isolates this variation and lets the service registry select it.

Do not force: Factory

The application can construct and inject the three senders directly. There is no complex creation rule.

Do not force: Singleton

Global access does not improve correctness, durability, or testing. Explicit construction makes executor ownership clear.

Do not force: Observer or Chain of Responsibility

There are no delivery subscribers or channel fallback chain in the confirmed requirements.

Connect the classes and review the final blueprint

NotificationService composes the sender registry, RetryPolicy, ExecutorService, and private DeliveryJobs. A worker delegates one attempt to a NotificationSender and reports the outcome back through DeliveryJob transitions. No inheritance is needed between the service, job, policy, or infrastructure.

  • Immutability: queued content and returned snapshots remain stable.
  • Encapsulation and thread safety: only DeliveryJob transition methods may change live status.
  • Single responsibility: the service coordinates while each sender owns provider-specific delivery.
  • Open/closed and dependency inversion: the workflow depends on NotificationSender rather than channel implementations.
  • Composition: executor and retry policy are explicit collaborators with visible ownership and lifecycle boundaries.
Sketch UML of notification records

How to read it: Request, receipt, snapshot, and retry records cross boundaries as stable data.

Design principle: Immutability prevents queued content from changing.

4. Java implementation (~15 minutes)

What the code does

The complete code validates immutable requests, indexes jobs, runs channel delivery in a fixed executor, retries expected failures, and exposes immutable snapshots.

Why it is structured this way

Senders own one provider attempt, DeliveryJob owns progress, and NotificationService coordinates the workflow without leaking mutable internals.

Follow one request through the code

  1. 1Construct a validated NotificationRequest.
  2. 2submit creates and indexes N001.
  3. 3The executor runs deliver(job) in the background.
  4. 4The sender succeeds or throws DeliveryException.
  5. 5DeliveryJob publishes SENT or FAILED through a snapshot.
Why the executor does not guarantee durabilityParallel work inside one process is not durable delivery.
A fixed thread pool makes delivery non-blocking and parallel while this Java process is alive. Its queue, workers, jobs, and IDs disappear on a crash. A production guarantee requires durable shared authority.
Why retries can duplicate deliveryA timeout can hide a provider success and trigger another attempt.
A provider may accept a message and then time out before replying. The worker sees failure and retries, although the first attempt may have succeeded. The first version promises bounded attempts, not exactly-once delivery.
Why synchronized job methods are enough hereOne lock keeps each job's related fields and snapshots consistent.
One worker owns delivery for each job, while status readers may run on other threads. Synchronizing transitions and snapshot creation gives readers consistent visibility without exposing mutable state.

Channels and delivery states

Read the fixed channel choices and observable job states first. Every later class uses these names instead of channel strings or ambiguous booleans.

NotificationChannel.java
Names the supported delivery channels.
public enum NotificationChannel {
  EMAIL,
  SMS,
  PUSH
}
DeliveryStatus.java
Names the observable lifecycle of one delivery job.
public enum DeliveryStatus {
  QUEUED,
  SENDING,
  SENT,
  FAILED
}

Immutable request and result records

These records validate accepted input and return stable data without exposing the mutable DeliveryJob.

NotificationRequest.java
Validates and preserves the accepted recipient, channel, and message.
public record NotificationRequest(
      String recipientId,
      String destination,
      NotificationChannel channel,
      String subject,
      String body) {
  public NotificationRequest {
      if (recipientId == null || recipientId.isBlank()
              || destination == null || destination.isBlank()
              || channel == null || body == null || body.isBlank()) {
          throw new IllegalArgumentException("Complete notification details are required");
      }
      subject = subject == null ? "" : subject;
  }
}
SubmissionReceipt.java
Returns the accepted job ID without waiting for delivery.
public record SubmissionReceipt(String jobId) {
  public SubmissionReceipt {
      if (jobId == null || jobId.isBlank()) {
          throw new IllegalArgumentException("Job id is required");
      }
  }
}
DeliverySnapshot.java
Publishes a consistent immutable view of job progress.
public record DeliverySnapshot(
      String jobId,
      DeliveryStatus status,
      int attempts,
      String lastError) {
}
RetryPolicy.java
Defines the maximum number of completed delivery attempts.
public record RetryPolicy(int maxAttempts) {
  public RetryPolicy {
      if (maxAttempts < 1) {
          throw new IllegalArgumentException("At least one attempt is required");
      }
  }

  public boolean canRetry(int completedAttempts) {
      return completedAttempts < maxAttempts;
  }
}

Sender boundary and channel implementations

Read the interface before its three implementations. Each sender performs one provider attempt; it does not own retry or job state.

DeliveryException.java
Represents an expected provider delivery failure.
public final class DeliveryException extends Exception {
  public DeliveryException(String message) {
      super(message);
  }
}
NotificationSender.java
Defines the replaceable operation for one channel attempt.
public interface NotificationSender {
  NotificationChannel channel();

  void send(NotificationRequest request) throws DeliveryException;
}
EmailSender.java
Implements deterministic local EMAIL delivery.
public final class EmailSender implements NotificationSender {
  @Override
  public NotificationChannel channel() {
      return NotificationChannel.EMAIL;
  }

  @Override
  public void send(NotificationRequest request) throws DeliveryException {
      System.out.println("EMAIL -> " + request.destination() + ": " + request.body());
  }
}
SmsSender.java
Implements deterministic local SMS delivery.
public final class SmsSender implements NotificationSender {
  @Override
  public NotificationChannel channel() {
      return NotificationChannel.SMS;
  }

  @Override
  public void send(NotificationRequest request) throws DeliveryException {
      System.out.println("SMS -> " + request.destination() + ": " + request.body());
  }
}
PushSender.java
Implements deterministic local PUSH delivery.
public final class PushSender implements NotificationSender {
  @Override
  public NotificationChannel channel() {
      return NotificationChannel.PUSH;
  }

  @Override
  public void send(NotificationRequest request) throws DeliveryException {
      System.out.println("PUSH -> " + request.destination() + ": " + request.body());
  }
}

Thread-safe job state

DeliveryJob is the only mutable state owner in the delivery path. Its synchronized methods change or publish related fields together.

DeliveryJob.java
Protects one job's mutable status, attempts, and latest error.
import java.util.Objects;

final class DeliveryJob {
  private final String jobId;
  private final NotificationRequest request;
  private DeliveryStatus status = DeliveryStatus.QUEUED;
  private int attempts;
  private String lastError;

  DeliveryJob(String jobId, NotificationRequest request) {
      this.jobId = Objects.requireNonNull(jobId);
      this.request = Objects.requireNonNull(request);
  }

  NotificationRequest request() {
      return request;
  }

  synchronized void startAttempt() {
      if (status == DeliveryStatus.SENT || status == DeliveryStatus.FAILED) {
          throw new IllegalStateException("Terminal job cannot restart");
      }
      attempts++;
      status = DeliveryStatus.SENDING;
  }

  synchronized void recordAttemptFailure(String message) {
      if (status != DeliveryStatus.SENDING) {
          throw new IllegalStateException("No active attempt");
      }
      lastError = Objects.requireNonNull(message);
  }

  synchronized void markSent() {
      if (status != DeliveryStatus.SENDING) {
          throw new IllegalStateException("No active attempt");
      }
      status = DeliveryStatus.SENT;
      lastError = null;
  }

  synchronized void markFailed() {
      if (status != DeliveryStatus.SENDING) {
          throw new IllegalStateException("No active attempt");
      }
      status = DeliveryStatus.FAILED;
  }

  synchronized int attempts() {
      return attempts;
  }

  synchronized DeliverySnapshot snapshot() {
      return new DeliverySnapshot(jobId, status, attempts, lastError);
  }
}

Service workflow and demo

NotificationService validates and indexes work before scheduling it. Read submit first, then the complete delivery loop, status lookup, and shutdown boundary.

NotificationService.java
Coordinates validation, indexing, background execution, and retry.
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;

public final class NotificationService implements AutoCloseable {
  private final Map<NotificationChannel, NotificationSender> senders;
  private final ConcurrentMap<String, DeliveryJob> jobs = new ConcurrentHashMap<>();
  private final ExecutorService executor;
  private final RetryPolicy retryPolicy;
  private final AtomicLong sequence = new AtomicLong(1);
  private final AtomicBoolean accepting = new AtomicBoolean(true);

  public NotificationService(List<NotificationSender> senders,
          ExecutorService executor, RetryPolicy retryPolicy) {
      this.executor = Objects.requireNonNull(executor);
      this.retryPolicy = Objects.requireNonNull(retryPolicy);
      EnumMap<NotificationChannel, NotificationSender> registry =
              new EnumMap<>(NotificationChannel.class);
      for (NotificationSender sender : List.copyOf(senders)) {
          if (registry.putIfAbsent(sender.channel(), sender) != null) {
              throw new IllegalArgumentException("Duplicate sender: " + sender.channel());
          }
      }
      this.senders = Map.copyOf(registry);
  }

  public SubmissionReceipt submit(NotificationRequest request) {
      Objects.requireNonNull(request);
      if (!accepting.get()) {
          throw new IllegalStateException("Service is closed");
      }
      if (!senders.containsKey(request.channel())) {
          throw new IllegalArgumentException("No sender for " + request.channel());
      }
      String jobId = "N" + String.format("%03d", sequence.getAndIncrement());
      DeliveryJob job = new DeliveryJob(jobId, request);
      jobs.put(jobId, job);
      try {
          executor.submit(() -> deliver(job));
      } catch (RejectedExecutionException failure) {
          jobs.remove(jobId);
          throw failure;
      }
      return new SubmissionReceipt(jobId);
  }

  private void deliver(DeliveryJob job) {
      NotificationSender sender = senders.get(job.request().channel());
      while (true) {
          job.startAttempt();
          try {
              sender.send(job.request());
              job.markSent();
              return;
          } catch (DeliveryException failure) {
              String message = failure.getMessage() == null
                      ? "Delivery failed"
                      : failure.getMessage();
              job.recordAttemptFailure(message);
              if (!retryPolicy.canRetry(job.attempts())) {
                  job.markFailed();
                  return;
              }
          }
      }
  }

  public Optional<DeliverySnapshot> getStatus(String jobId) {
      if (jobId == null || jobId.isBlank()) {
          return Optional.empty();
      }
      DeliveryJob job = jobs.get(jobId);
      return job == null ? Optional.empty() : Optional.of(job.snapshot());
  }

  @Override
  public void close() {
      if (accepting.compareAndSet(true, false)) {
          executor.shutdown();
      }
  }
}
NotificationDemo.java
Runs one accepted EMAIL delivery through the public API.
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public final class NotificationDemo {
  public static void main(String[] args) throws InterruptedException {
      ExecutorService workers = Executors.newFixedThreadPool(2);
      try (NotificationService service = new NotificationService(
              List.of(new EmailSender(), new SmsSender(), new PushSender()),
              workers,
              new RetryPolicy(3))) {
          SubmissionReceipt receipt = service.submit(new NotificationRequest(
                  "U1", "learner@example.com", NotificationChannel.EMAIL,
                  "Interview update", "Your interview starts at 3 PM."));
          System.out.println("Accepted " + receipt.jobId());
          workers.shutdown();
          workers.awaitTermination(2, TimeUnit.SECONDS);
          System.out.println(service.getStatus(receipt.jobId()).orElseThrow());
      }
  }
}

A successful EMAIL submission

  1. 1The record constructor validates U1, learner@example.com, EMAIL, and the body.
  2. 2submit confirms the service is accepting and has an EMAIL sender.
  3. 3The service creates N001, indexes a QUEUED job, and submits its worker.
  4. 4The caller receives SubmissionReceipt(N001) before provider completion is required.
  5. 5The worker starts attempt 1, EmailSender succeeds, and the job atomically becomes SENT.
  6. 6getStatus(N001) returns a DeliverySnapshot with SENT and attempts=1.

A permanent provider failure

  1. 1The worker records failures after attempts 1 and 2; RetryPolicy still permits another attempt.
  2. 2Attempt 3 also throws DeliveryException.
  3. 3RetryPolicy denies a fourth attempt, so DeliveryJob becomes FAILED with attempts=3.
  4. 4No unrelated job changes, and callers can inspect the final error through a snapshot.

Deterministic worker lab

Notification delivery

Submit returns immediately. Advance a worker to perform one attempt.

No jobs yet

What this proves: acceptance and delivery are separate events. The caller gets an ID before any attempt, while each worker updates only its own job.

5. Extensions (~5 minutes)

Durable delivery across crashes

The changed requirement is “accepted jobs must survive a process crash.” NotificationService currently owns an in-memory executor and job index, so those can no longer be the authority. Persist an outbox record in the same transaction as the caller's business action or publish to a durable broker. Workers acknowledge only after delivery handling, failed jobs move to a dead-letter destination, and stable delivery keys reduce duplicate effects.

Backoff, jitter, and rate limits

The current RetryPolicy decides only whether another immediate attempt is allowed. A delayed retry requirement introduces scheduled time. Replace the immediate loop with a retry scheduler that calculates the next attempt time. Add jitter so many jobs do not retry together, and keep provider-specific rate limits beside the sender or its adapter.

New channels, templates, and fallback

WhatsApp or Slack adds a channel value and NotificationSender; the service workflow remains unchanged. Templates introduce a renderer before submission so the accepted request still contains final immutable content. Channel fallback is different: it turns one request into an ordered multi-channel plan and may justify a Chain of Responsibility only after the fallback requirement is precise.