Notification System
Prefer to learn one decision at a time?
Try questions, reveal the reasoning, trace the code, and finish with the simulator.
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.

1. Requirements (~5 minutes)
Use these four question groups every time
What must a user be able to do?
When does an action succeed, fail, or change state?
Which invalid actions must we reject?
What should we deliberately not build?
Ask these questions
AskWhen submit returns successfully, has the message been delivered?Show answerHide answer
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?Show answerHide answer
Interviewer answer
Support EMAIL, SMS, and PUSH. Require recipient ID, channel-specific destination, and body. Subject is optional.
AskHow should failure and retry work?Show answerHide answer
Interviewer answer
Try at most three times in total. The first version retries immediately.
AskWhat may callers observe?Show answerHide answer
Interviewer answer
Return QUEUED, SENDING, SENT, or FAILED plus attempts and the latest error.
AskWhat concurrency and durability should we promise?Show answerHide answer
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.Support EMAIL, SMS, and PUSH through replaceable senders.
- 2.A validated immutable request contains recipient ID, destination, one channel, optional subject, and mandatory body.
- 3.Submit indexes a QUEUED job and immediately returns its ID.
- 4.A fixed ExecutorService runs separate jobs in parallel.
- 5.Provider failures receive no more than three total immediate attempts.
- 6.Callers can query an immutable status snapshot.
- 7.Shutdown rejects new work and lets accepted executor tasks finish.
- 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.
Preserves the validated content accepted for delivery.
Owns one job's status, attempts, and latest failure.
Defines one replaceable channel delivery attempt.
Owns EMAIL provider-specific delivery behavior.
Owns SMS provider-specific delivery behavior.
Owns PUSH provider-specific delivery behavior.
Names and validates the total-attempt boundary.
Coordinates submission, indexing, workers, and lifecycle.
Returns the accepted job ID immediately.
Exposes progress without leaking the mutable job.
Enums
Enums name a small, fixed set of valid choices or outcomes.
Limits delivery to EMAIL, SMS, or PUSH.
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.
Lets callers query one accepted delivery job.
Identifies the notification recipient.
Stores the email address, phone number, or device target.
Carries optional channel content.
Carries the mandatory message content.
Counts completed delivery attempts.
Preserves the latest provider failure for status queries.
Supporting implementation
Runs accepted delivery jobs on worker threads.
Holds in-memory tasks waiting for a worker.
Swipe to follow the flow →

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.
Selects channel behavior without service branches.
Publishes accepted jobs safely to querying callers.
Runs one worker task per accepted job.
Defines the total-attempt boundary.
Allocates unique IDs across concurrent submissions.
Prevents submission after shutdown begins across concurrent callers.
Behavior it owns
Public use cases and internal rules kept beside that state.
Validates, indexes QUEUED state, submits work, and returns the receipt.
Returns an immutable snapshot or an empty result.
Stops new submissions and starts graceful executor shutdown.
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.
Carry validated immutable request, receipt, snapshot, and attempt-policy data.
Stable values can cross caller and worker threads without exposing mutable DeliveryJob state.
Defines channel() and one provider delivery attempt.
The service registry depends on this contract and contains no EMAIL, SMS, or PUSH branches.
Own provider-specific delivery for one channel each.
They do not retry, index jobs, or change service lifecycle.
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
Do not force: Factory
Do not force: Singleton
Do not force: Observer or Chain of Responsibility
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.

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
- 1Construct a validated NotificationRequest.
- 2submit creates and indexes N001.
- 3The executor runs deliver(job) in the background.
- 4The sender succeeds or throws DeliveryException.
- 5DeliveryJob publishes SENT or FAILED through a snapshot.
Why the executor does not guarantee durabilityParallel work inside one process is not durable delivery.
Why retries can duplicate deliveryA timeout can hide a provider success and trigger another attempt.
Why synchronized job methods are enough hereOne lock keeps each job's related fields and snapshots consistent.
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.
public enum NotificationChannel {
EMAIL,
SMS,
PUSH
}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.
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;
}
}public record SubmissionReceipt(String jobId) {
public SubmissionReceipt {
if (jobId == null || jobId.isBlank()) {
throw new IllegalArgumentException("Job id is required");
}
}
}public record DeliverySnapshot(
String jobId,
DeliveryStatus status,
int attempts,
String lastError) {
}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.
public final class DeliveryException extends Exception {
public DeliveryException(String message) {
super(message);
}
}public interface NotificationSender {
NotificationChannel channel();
void send(NotificationRequest request) throws DeliveryException;
}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());
}
}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());
}
}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.
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.
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();
}
}
}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
- 1The record constructor validates U1, learner@example.com, EMAIL, and the body.
- 2submit confirms the service is accepting and has an EMAIL sender.
- 3The service creates N001, indexes a QUEUED job, and submits its worker.
- 4The caller receives SubmissionReceipt(N001) before provider completion is required.
- 5The worker starts attempt 1, EmailSender succeeds, and the job atomically becomes SENT.
- 6getStatus(N001) returns a DeliverySnapshot with SENT and attempts=1.
A permanent provider failure
- 1The worker records failures after attempts 1 and 2; RetryPolicy still permits another attempt.
- 2Attempt 3 also throws DeliveryException.
- 3RetryPolicy denies a fourth attempt, so DeliveryJob becomes FAILED with attempts=3.
- 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.
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.