Low Level Design

Design a Notification Service

A multi-channel notification broker with per-service subscription control, parallel fan-out via an ExecutorService, exponential-backoff retry, and a Dead Letter Queue — built around Template Method, Strategy, Builder, and Singleton.

August 16, 2026·16 min read

Problem Description#

Design a Notification Service that acts as a central broker — backend services publish notification requests, and the system fans them out to subscribed clients through whichever channels (Email, SMS, Push) each client has opted into.

The interesting complexity here is the two-layer permission model: a backend service declares which channels it is allowed to use, and a client independently subscribes to a subset of those channels. Delivery only happens through channels that satisfy both constraints. Add parallel fan-out, retry logic with exponential backoff, and a Dead Letter Queue for undeliverable messages — and you have a system that touches concurrency, extensibility, and fault tolerance all at once.

This problem is rich in design patterns: Singleton (single entry point), Template Method (fixed delivery sequence), Strategy (pluggable channels), Builder (request construction), and Factory (object creation with UUID generation).


Clarify Requirements#

Before designing, ask these questions in an interview:

Functional

  • What channels must be supported initially? Can new channels be added without touching existing code?
  • Who controls which channels a service may use — the service itself or a central admin?
  • Can a client subscribe to a channel the service doesn't allow?
  • Should notifications be delivered synchronously or asynchronously?
  • What happens when a delivery fails — retry? How many times? With what delay?
  • Where do permanently-failed messages go?

Non-functional

  • How many concurrent notification requests should the system handle?
  • Is thread safety required for subscription management?
  • Should the DLQ be in-memory or persisted?
  • Is a single NotificationSystem instance shared across the whole application?

Final Requirements#

After clarification, here's what we'll build:

  • NotificationSystem Singleton — single entry point for registration, subscription, and sending
  • Services declare an allow-list of channels; subscribing a client to a channel not on the allow-list throws immediately
  • Subscriptions are stored as Map<serviceId, Map<clientId, List<Channel>>> in SubscriptionRepository
  • NotificationService fans out each request to all (client, channel) pairs in parallel via ExecutorService
  • Each channel uses the Template Method pattern: validate → formatMessage → send → logResult; subclasses override only formatting and delivery
  • Failed deliveries are retried up to 3 times with exponential backoff (1 s → 2 s → 4 s)
  • Requests that exhaust all retries land in a Dead Letter Queue (ConcurrentLinkedQueue) for audit
  • ClientFactory and ServiceFactory generate UUIDs and construct domain objects — callers never call new Client() directly

Core Entities#

EntityResponsibility
ClientImmutable value object — id, name, email, phone number, device token
ServiceIdentifies a backend service and its allow-listed channel types
NotificationRequestImmutable request built via Builder — id, serviceId, message, timestamp
NotificationChannelAbstract base — defines the fixed delivery sequence via Template Method
EmailNotificationChannelHTML-wraps the message; sends via email gateway
SMSNotificationChannelTruncates to 160 chars; sends via SMS gateway
PushNotificationChannelTruncates to 100 chars; sends via push provider
SubscriptionRepositoryStores subscriptions keyed by (serviceId, clientId) → List<ChannelType>
ClientInventoryThread-safe CRUD store for Client objects
ServiceInventoryThread-safe CRUD store for Service objects
NotificationServiceFan-out engine — parallel delivery, retry with backoff, DLQ
NotificationSystemSingleton entry point — wires all components, enforces allow-list at subscribe time
ClientFactoryStatic factory: creates Client with a generated UUID
ServiceFactoryStatic factory: creates Service with a generated UUID

Patterns Used#

1. Singleton — NotificationSystem#

The entire application shares one NotificationSystem instance. It owns all four sub-components (ClientInventory, ServiceInventory, SubscriptionRepository, NotificationService) and is the only public surface area callers ever touch. Lazy initialisation is guarded with synchronized to be thread-safe.

2. Template Method — NotificationChannel#

NotificationChannel defines a fixed, final delivery sequence:

sendNotification(request, client)
  1. validate(request, client)     ← common; subclasses may override
  2. formatMessage(request, client) ← abstract; each channel implements
  3. send(formatted, client)        ← abstract; each channel implements
  4. logResult(client, formatted)   ← common; subclasses may override

The method is final so no subclass can reorder the steps. formatMessage and send are abstract, forcing each channel to provide its own version. Adding a Slack channel means subclassing NotificationChannel and implementing two methods — nothing else changes.

3. Strategy — concrete channel implementations#

Each channel class (EmailNotificationChannel, SMSNotificationChannel, PushNotificationChannel) is an interchangeable strategy. NotificationService refers to channels only by their Class<? extends NotificationChannel> type, instantiating them via reflection at delivery time. This means the set of available channels is open for extension without modifying the service.

4. Builder — NotificationRequest#

NotificationRequest has an auto-generated UUID and a defaultable timestamp — messy to pass as a long constructor argument list. The nested Builder lets callers set only what they care about:

java
new NotificationRequest.Builder()
    .setServiceId(service1.getId())
    .setMessage("It's going to rain today!")
    .build();

5. Factory — ClientFactory and ServiceFactory#

Both factories hide UUID generation and constructor calls. Callers never deal with IDs — the factory produces a fully formed Client or Service with a guaranteed-unique identifier.


Code#

Models — Client, Service, NotificationRequest#

Client and Service are immutable value objects. NotificationRequest is built via the Builder pattern with an auto-generated UUID and optional timestamp.

java
public class Client {
    private final String id;
    private final String name;
    private final String email;
    private final String phoneNumber;
    private final String deviceToken;

    public Client(String id, String name, String email, String phoneNumber, String deviceToken) {
        this.id          = id;
        this.name        = name;
        this.email       = email;
        this.phoneNumber = phoneNumber;
        this.deviceToken = deviceToken;
    }

    public String getId()          { return id; }
    public String getName()        { return name; }
    public String getEmail()       { return email; }
    public String getPhoneNumber() { return phoneNumber; }
    public String getDeviceToken() { return deviceToken; }
}

Factories — ClientFactory, ServiceFactory#

java
import java.util.UUID;

public class ClientFactory {
    public static Client createClient(String name, String email,
                                      String phoneNumber, String deviceToken) {
        return new Client(UUID.randomUUID().toString(), name, email, phoneNumber, deviceToken);
    }
}

Repositories — ClientInventory, ServiceInventory, SubscriptionRepository#

ClientInventory and ServiceInventory are thread-safe CRUD stores backed by ConcurrentHashMap. SubscriptionRepository is the heart of the data model — it stores which channels each client subscribes to per service, and enforces the lookup contract used by the delivery engine.

java
import java.util.Collection;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

public class ClientInventory {
    private final Map<String, Client> clients = new ConcurrentHashMap<>();

    public void addClient(Client client)             { clients.put(client.getId(), client); }
    public Optional<Client> getClient(String id)     { return Optional.ofNullable(clients.get(id)); }
    public Collection<Client> getAllClients()         { return clients.values(); }
    public void removeClient(String clientId)        { clients.remove(clientId); }
}

Channels — Template Method + Strategy#

NotificationChannel is the abstract base. Its sendNotification method is final — the sequence is fixed. Concrete channels only override formatMessage and send.

java
public abstract class NotificationChannel {

    // Template method — fixed sequence, cannot be overridden
    public final void sendNotification(NotificationRequest request, Client client) {
        if (!validate(request, client)) {
            System.out.println("Validation failed for request: " + request.getId());
            return;
        }
        String formatted = formatMessage(request, client);
        send(formatted, client);
        logResult(client, formatted);
    }

    protected boolean validate(NotificationRequest request, Client client) {
        return request != null
                && request.getMessage() != null
                && !request.getMessage().trim().isEmpty()
                && client != null;
    }

    protected abstract String formatMessage(NotificationRequest request, Client client);

    protected abstract void send(String formattedMessage, Client client);

    protected void logResult(Client client, String formattedMessage) {
        System.out.printf("[%s] ✓ Delivered to %s | Message: %s%n",
                this.getClass().getSimpleName(), client.getName(), formattedMessage);
    }
}

Delivery engine and entry point#

NotificationService fans out to all (client, channel) pairs in parallel, retries on failure with exponential backoff, and routes exhausted requests to the DLQ. NotificationSystem is the Singleton that wires everything together and enforces the channel allow-list at subscription time.

java
import java.util.*;
import java.util.concurrent.*;

public class NotificationService {

    private static final int MAX_RETRIES       = 3;
    private static final long INITIAL_BACKOFF_MS = 1000;

    private final SubscriptionRepository subscriptionRepository;
    private final ExecutorService executor;
    private final Queue<NotificationRequest> dlq = new ConcurrentLinkedQueue<>();

    public NotificationService(SubscriptionRepository subscriptionRepository) {
        this.subscriptionRepository = subscriptionRepository;
        this.executor = Executors.newCachedThreadPool();
    }

    public void processNotification(NotificationRequest request, Service service) {
        List<Client> clients = subscriptionRepository.getSubscribedClients(service);
        List<Future<?>> futures = new ArrayList<>();

        for (Client client : clients) {
            List<Class<? extends NotificationChannel>> channels =
                    subscriptionRepository.getSubscribedChannels(service, client);
            for (Class<? extends NotificationChannel> channelClass : channels) {
                futures.add(executor.submit(() -> sendWithRetry(request, client, channelClass)));
            }
        }

        for (Future<?> future : futures) {
            try {
                future.get(); // wait for each delivery to complete
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } catch (ExecutionException e) {
                System.out.println("Delivery error: " + e.getCause().getMessage());
            }
        }
    }

    private void sendWithRetry(NotificationRequest request, Client client,
                               Class<? extends NotificationChannel> channelClass) {
        int attempts = 0;
        long backoff = INITIAL_BACKOFF_MS;

        while (attempts < MAX_RETRIES) {
            try {
                NotificationChannel channel = channelClass.getDeclaredConstructor().newInstance();
                channel.sendNotification(request, client);
                return; // success — exit retry loop
            } catch (Exception e) {
                attempts++;
                System.out.printf("Attempt %d failed for %s → %s: %s%n",
                        attempts, channelClass.getSimpleName(), client.getName(), e.getMessage());
                if (attempts < MAX_RETRIES) {
                    try { Thread.sleep(backoff); } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                    }
                    backoff *= 2; // exponential backoff: 1s → 2s → 4s
                }
            }
        }
        addToDLQ(request);
    }

    private void addToDLQ(NotificationRequest request) {
        System.out.println("All retries exhausted. Moving to DLQ: " + request.getId());
        dlq.add(request);
    }

    public Queue<NotificationRequest> getDLQ() { return dlq; }

    public void shutdown() { executor.shutdown(); }
}

Demo#

java
import java.util.List;

public class NotificationSystemDemo {
    public static void main(String[] args) {
        // Create clients and services via factories (UUIDs are auto-generated)
        Client client1 = ClientFactory.createClient("Alice", "alice@gmail.com", "1234567890", "tokenAlice");
        Client client2 = ClientFactory.createClient("Ben",   "ben@gmail.com",   "1234237890", "tokenBen");

        Service weatherService = ServiceFactory.createService(
                "Weather Updates", List.of(EmailNotificationChannel.class, SMSNotificationChannel.class));
        Service stockService = ServiceFactory.createService(
                "Stock Alerts", List.of(EmailNotificationChannel.class, PushNotificationChannel.class));

        NotificationSystem system = NotificationSystem.getInstance();

        system.registerClient(client1);
        system.registerClient(client2);
        system.registerService(weatherService);
        system.registerService(stockService);

        // Alice subscribes to Weather via Email + SMS
        system.subscribe(weatherService, client1, EmailNotificationChannel.class);
        system.subscribe(weatherService, client1, SMSNotificationChannel.class);
        // Ben subscribes to Weather via Email, Stock Alerts via Push
        system.subscribe(weatherService, client2, EmailNotificationChannel.class);
        system.subscribe(stockService,   client2, PushNotificationChannel.class);

        // This would throw — Push is not in weatherService.allowedChannels
        // system.subscribe(weatherService, client1, PushNotificationChannel.class);

        NotificationRequest weatherAlert = new NotificationRequest.Builder()
                .setServiceId(weatherService.getId())
                .setMessage("It's going to rain today!")
                .build();

        NotificationRequest stockAlert = new NotificationRequest.Builder()
                .setServiceId(stockService.getId())
                .setMessage("AAPL stock price just hit $150!")
                .build();

        system.sendNotification(weatherAlert);  // Alice (Email+SMS), Ben (Email)
        system.sendNotification(stockAlert);    // Ben (Push)
    }
}

Class Diagram#


Extendible — Follow Ups#

1. Add a new channel without touching any existing class#

Create SlackNotificationChannel extends NotificationChannel, override formatMessage (build a Slack-flavoured JSON payload) and send (call the Slack webhook). Register the class in any service's allowedChannels list. Zero changes to NotificationSystem, NotificationService, or existing channels — this is Open/Closed in action.

2. Persistent Dead Letter Queue with replay#

Replace the in-memory ConcurrentLinkedQueue<NotificationRequest> with a DLQRepository interface backed by a database or message queue (Kafka, SQS). Add a replayDLQ() method to NotificationService that reads unacknowledged entries and re-submits them through the normal delivery path.

3. Priority notifications#

Add a priority field to NotificationRequest (HIGH / NORMAL / LOW). Replace Executors.newCachedThreadPool() with a PriorityBlockingQueue-backed ThreadPoolExecutor that sorts submitted tasks by priority. HIGH-priority notifications jump ahead of queued NORMAL ones.

4. Channel-level preference per client#

Allow a client to set preferred time windows or opt-out of a channel entirely via a ClientChannelPreference record. NotificationService.processNotification checks preferences before submitting each (client, channel) task — no delivery happens outside the client's declared window.

5. Metrics and observability#

Wrap NotificationService with a decorator that increments Prometheus counters on every delivered, retried, and dlq event, tagged by channel type and service ID. The decorator implements the same processNotification interface, so it can be swapped in transparently.

6. Asynchronous request acceptance#

Change sendNotification to push NotificationRequest objects into a BlockingQueue. A pool of worker threads drains the queue and calls processNotification. The caller returns immediately after enqueuing — delivery is fire-and-forget — which is the model used by Kafka producers and most real notification pipelines.