Sitemap
Niyaz Ahmad

Niyaz Ahmad

Software Engineer 2 at Avalara

LLD-2 Notification Service

4 min readFeb 17, 2026

--

In this post, we’ll design a modular, extensible notification service in Java using clean OOP principles and a few patterns:

  • Strategy (channel-specific send behavior)
  • Factory/Registry (lookup senders by channel)
  • Facade (a simple NotificationService API)

High-Level Components

We’ll build around these key objects:

  • Channel → EMAIL / SMS / PUSH
  • Notification → common contract (recipient + content + channel)
  • Concrete Notifications → EmailNotification, SmsNotification, PushNotification
  • NotificationSender → sends a notification (channel strategy)
  • SchedulableSender → optional scheduling capability
  • SenderFactory (Registry) → finds sender by channel
  • NotificationDispatcher → routes requests to the right sender
  • NotificationService (Facade) → public API: send() / schedule()

Channel

public enum Channel {
EMAIL, SMS, PUSH
}

Notification Model

We keep a shared interface so the dispatcher can treat all notifications uniformly.

public interface Notification {
Channel getChannel();
String getRecipient();
String getContent();
}

Email Notification

Email has extra fields like subject, but still follows the shared contract.

public final class EmailNotification implements Notification {
private final String toEmail;
private final String subject;
private final String body;

public EmailNotification(String toEmail, String subject, String body) {
this.toEmail = toEmail;
this.subject = subject;
this.body = body;
}

@Override
public Channel getChannel() { return Channel.EMAIL; }

@Override
public String getRecipient() { return toEmail; }

@Override
public String getContent() { return body; }

public String getSubject() { return subject; }
}

SMS Notification

public final class SmsNotification implements Notification {
private final String toPhone;
private final String message;

public SmsNotification(String toPhone, String message) {
this.toPhone = toPhone;
this.message = message;
}

@Override
public Channel getChannel() { return Channel.SMS; }

@Override
public String getRecipient() { return toPhone; }

@Override
public String getContent() { return message; }
}

Push Notification

public final class PushNotification implements Notification {
private final String deviceId;
private final String title;
private final String payload;

public PushNotification(String deviceId, String title, String payload) {
this.deviceId = deviceId;
this.title = title;
this.payload = payload;
}

@Override
public Channel getChannel() { return Channel.PUSH; }

@Override
public String getRecipient() { return deviceId; }

@Override
public String getContent() { return payload; }

public String getTitle() { return title; }
}

Sender (Strategy)

Each channel has its own sender implementation. That’s Strategy: same interface, different behavior.

public interface NotificationSender {
void send(Notification notification);
}

Scheduling is not always universal, so we keep it separate.

import java.time.LocalDateTime;

public interface SchedulableNotificationSender extends NotificationSender {
void schedule(Notification notification, LocalDateTime at);
}

Concrete Senders

public class EmailSender implements SchedulableNotificationSender {
@Override
public void send(Notification n) {
System.out.println("Sending EMAIL to " + n.getRecipient());
}

@Override
public void schedule(Notification n, LocalDateTime at) {
System.out.println("Scheduling EMAIL to " + n.getRecipient() + " at " + at);
}
}

public class PushSender implements SchedulableNotificationSender {
@Override
public void send(Notification n) {
System.out.println("Sending PUSH to " + n.getRecipient());
}

@Override
public void schedule(Notification n, LocalDateTime at) {
System.out.println("Scheduling PUSH to " + n.getRecipient() + " at " + at);
}
}

/**
* SMS scheduling is intentionally NOT supported in this design,
* to show how capabilities can differ per channel.
*/
public class SmsSender implements NotificationSender {
@Override
public void send(Notification n) {
System.out.println("Sending SMS to " + n.getRecipient());
}
}

Sender Factory (Registry + Factory)

Instead of if/else or switch everywhere, we register senders once and look them up by channel.

import java.util.EnumMap;
import java.util.Map;
import java.util.Optional;

public interface NotificationSenderFactory {
Optional<NotificationSender> getSender(Channel channel);
Optional<SchedulableNotificationSender> getSchedulableSender(Channel channel);
}

public class DefaultNotificationSenderFactory implements NotificationSenderFactory {
private final Map<Channel, NotificationSender> senderMap = new EnumMap<>(Channel.class);
private final Map<Channel, SchedulableNotificationSender> schedulableMap = new EnumMap<>(Channel.class);

public DefaultNotificationSenderFactory() {
EmailSender emailSender = new EmailSender();
PushSender pushSender = new PushSender();
SmsSender smsSender = new SmsSender();

senderMap.put(Channel.EMAIL, emailSender);
senderMap.put(Channel.PUSH, pushSender);
senderMap.put(Channel.SMS, smsSender);

schedulableMap.put(Channel.EMAIL, emailSender);
schedulableMap.put(Channel.PUSH, pushSender);
// Note: SMS not registered as schedulable
}

@Override
public Optional<NotificationSender> getSender(Channel channel) {
return Optional.ofNullable(senderMap.get(channel));
}

@Override
public Optional<SchedulableNotificationSender> getSchedulableSender(Channel channel) {
return Optional.ofNullable(schedulableMap.get(channel));
}
}

Dispatcher (Routing + Orchestration)

The dispatcher routes the request to the correct sender and cleanly handles unsupported operations.

import java.time.LocalDateTime;

public class NotificationDispatcher {
private final NotificationSenderFactory factory;

public NotificationDispatcher(NotificationSenderFactory factory) {
this.factory = factory;
}

public void dispatch(Notification notification) {
factory.getSender(notification.getChannel())
.ifPresentOrElse(
sender -> sender.send(notification),
() -> System.out.println("Unsupported channel: " + notification.getChannel())
);
}

public void schedule(Notification notification, LocalDateTime at) {
factory.getSchedulableSender(notification.getChannel())
.ifPresentOrElse(
sender -> sender.schedule(notification, at),
() -> System.out.println("Scheduling not supported for channel: " + notification.getChannel())
);
}
}

NotificationService Facade (Public API)

Facade keeps the outside world dead simple: send now, schedule later.

import java.time.LocalDateTime;

public class NotificationService {
private final NotificationDispatcher dispatcher;

public NotificationService(NotificationDispatcher dispatcher) {
this.dispatcher = dispatcher;
}

public void send(Notification notification) {
dispatcher.dispatch(notification);
}

public void schedule(Notification notification, LocalDateTime at) {
dispatcher.schedule(notification, at);
}
}

Usage

import java.time.LocalDateTime;

public class App {
public static void main(String[] args) {
NotificationSenderFactory factory = new DefaultNotificationSenderFactory();
NotificationDispatcher dispatcher = new NotificationDispatcher(factory);
NotificationService service = new NotificationService(dispatcher);

Notification email = new EmailNotification("user@example.com", "Welcome", "Hello!");
service.send(email);
service.schedule(email, LocalDateTime.now().plusHours(2));

Notification sms = new SmsNotification("1234567890", "Hi there!");
service.send(sms);
service.schedule(sms, LocalDateTime.now().plusMinutes(30)); // prints "not supported"
}
}

Improvements for Production: Queue + Rate Limiting / Throttling

This in-memory design is a great LLD baseline, but production systems almost always evolve in two big directions:

1) Queue-based Delivery & Scheduling

Directly sending inside the application thread is risky:

  • what if the provider is slow?
  • what if the service restarts?
  • what if 1M notifications arrive at once?

Upgrade path:

  • send() publishes a message to a queue (Kafka / RabbitMQ / SQS)
  • workers consume messages and call channel senders
  • scheduling becomes durable:
  • store scheduled jobs in DB, and enqueue when due
  • or use a delayed queue / scheduler service

Result: reliability, backpressure handling, replay, and better scalability.

2) Rate Limiting / Throttling

Providers often enforce limits (per second/minute), and you might also want tenant-level control.

Get Niyaz Ahmad’s stories in your inbox

Join Medium for free to get updates from this writer.

Typical solutions:

  • token bucket / leaky bucket per provider + per tenant
  • throttle at the worker consumer level (control concurrency)
  • add retry with backoff on 429 or transient failures
  • dead-letter queue (DLQ) for messages that keep failing

Result: you avoid provider bans, stabilize throughput, and protect the system during spikes.

--

--

Niyaz Ahmad
Niyaz Ahmad

Written by Niyaz Ahmad

Software Engineer 2 at Avalara