LLD-2 Notification Service
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.







