Code · LLD machine coding · PhonePe prep
Notification System
Design a service that sends notifications to users over several channels.
Question
Design a service that sends notifications to users over several channels.
- Channels: email, SMS, push and in-app; more can be added.
- Each user has preferred channels. Send only on channels that are both requested and preferred.
- Notifications have a title, message, priority and metadata.
- Delivery is asynchronous. Failed sends are retried.
- Other parts of the system can observe delivery results (e.g. logging).
Design: singleton service with an executor, Builder for Notification, a Factory for channels,
a retry decorator (RetryableNotificationChannel) and Observer for delivery results. Run NotificationSystemDemo.
channel/EmailChannel.java 23 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.channel;
import com.practice.phonepe.questions.notificationsystem.entities.ChannelType;
import com.practice.phonepe.questions.notificationsystem.entities.Notification;
import com.practice.phonepe.questions.notificationsystem.exception.NotificationDeliveryException;
import java.util.concurrent.ThreadLocalRandom;
public class EmailChannel implements NotificationChannel {
@Override
public void send(Notification notification) throws NotificationDeliveryException {
String email = notification.getRecipient().getEmail();
if (email == null || email.isBlank()) {
throw new NotificationDeliveryException("User has no email on file");
}
if (ThreadLocalRandom.current().nextInt(100) < 20) {
throw new NotificationDeliveryException("SMTP gateway timeout for " + email);
}
System.out.printf("[EMAIL] to=%s subject=\"%s\" body=\"%s\"%n",
email, notification.getTitle(), notification.getMessage());
}
@Override
public ChannelType getType() {
return ChannelType.EMAIL;
}
} channel/InAppChannel.java 15 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.channel;
import com.practice.phonepe.questions.notificationsystem.entities.ChannelType;
import com.practice.phonepe.questions.notificationsystem.entities.Notification;
import com.practice.phonepe.questions.notificationsystem.exception.NotificationDeliveryException;
public class InAppChannel implements NotificationChannel {
@Override
public void send(Notification notification) throws NotificationDeliveryException {
System.out.printf("[IN_APP] user=%s title=\"%s\" body=\"%s\"%n",
notification.getRecipient().getId(), notification.getTitle(), notification.getMessage());
}
@Override
public ChannelType getType() {
return ChannelType.IN_APP;
}
} channel/NotificationChannel.java 13 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.channel;
import com.practice.phonepe.questions.notificationsystem.entities.ChannelType;
import com.practice.phonepe.questions.notificationsystem.entities.Notification;
import com.practice.phonepe.questions.notificationsystem.exception.NotificationDeliveryException;
/**
* Strategy interface: one implementation per delivery channel (Email, SMS, Push, In-App).
* New channels are added by implementing this interface + registering in the factory —
* no existing code changes (Open/Closed).
*/
public interface NotificationChannel {
void send(Notification notification) throws NotificationDeliveryException;
ChannelType getType();
} channel/NotificationChannelFactory.java 23 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.channel;
import com.practice.phonepe.questions.notificationsystem.entities.ChannelType;
import java.util.EnumMap;
import java.util.Map;
public class NotificationChannelFactory {
private final Map<ChannelType, NotificationChannel> channels = new EnumMap<>(ChannelType.class);
public NotificationChannelFactory() {
register(new EmailChannel());
register(new SmsChannel());
register(new PushChannel());
register(new InAppChannel());
}
public final void register(NotificationChannel channel) {
channels.put(channel.getType(), channel);
}
public NotificationChannel get(ChannelType type) {
NotificationChannel channel = channels.get(type);
if (channel == null) {
throw new IllegalArgumentException("No channel registered for type: " + type);
}
return channel;
}
} channel/PushChannel.java 23 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.channel;
import com.practice.phonepe.questions.notificationsystem.entities.ChannelType;
import com.practice.phonepe.questions.notificationsystem.entities.Notification;
import com.practice.phonepe.questions.notificationsystem.exception.NotificationDeliveryException;
import java.util.concurrent.ThreadLocalRandom;
public class PushChannel implements NotificationChannel {
@Override
public void send(Notification notification) throws NotificationDeliveryException {
String deviceToken = notification.getRecipient().getDeviceToken();
if (deviceToken == null || deviceToken.isBlank()) {
throw new NotificationDeliveryException("User has no registered device");
}
if (ThreadLocalRandom.current().nextInt(100) < 20) {
throw new NotificationDeliveryException("Push provider unreachable for device " + deviceToken);
}
System.out.printf("[PUSH] device=%s title=\"%s\" body=\"%s\"%n",
deviceToken, notification.getTitle(), notification.getMessage());
}
@Override
public ChannelType getType() {
return ChannelType.PUSH;
}
} channel/RetryableNotificationChannel.java 50 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.channel;
import com.practice.phonepe.questions.notificationsystem.entities.ChannelType;
import com.practice.phonepe.questions.notificationsystem.entities.Notification;
import com.practice.phonepe.questions.notificationsystem.exception.NotificationDeliveryException;
/**
* Decorator: adds retry-with-backoff around any {@link NotificationChannel}
* without the underlying channel implementation knowing about retries.
*/
public class RetryableNotificationChannel implements NotificationChannel {
private final NotificationChannel delegate;
private final int maxAttempts;
private final long initialBackoffMillis;
public RetryableNotificationChannel(NotificationChannel delegate, int maxAttempts, long initialBackoffMillis) {
this.delegate = delegate;
this.maxAttempts = maxAttempts;
this.initialBackoffMillis = initialBackoffMillis;
}
@Override
public void send(Notification notification) throws NotificationDeliveryException {
long backoff = initialBackoffMillis;
NotificationDeliveryException lastFailure = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
delegate.send(notification);
return;
} catch (NotificationDeliveryException e) {
lastFailure = e;
System.out.printf("[RETRY] %s attempt %d/%d failed for notification %s: %s%n",
delegate.getType(), attempt, maxAttempts, notification.getId(), e.getMessage());
if (attempt < maxAttempts) {
sleep(backoff);
backoff *= 2;
}
}
}
throw new NotificationDeliveryException(
"Delivery failed via " + delegate.getType() + " after " + maxAttempts + " attempts", lastFailure);
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
@Override
public ChannelType getType() {
return delegate.getType();
}
} channel/SmsChannel.java 22 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.channel;
import com.practice.phonepe.questions.notificationsystem.entities.ChannelType;
import com.practice.phonepe.questions.notificationsystem.entities.Notification;
import com.practice.phonepe.questions.notificationsystem.exception.NotificationDeliveryException;
import java.util.concurrent.ThreadLocalRandom;
public class SmsChannel implements NotificationChannel {
@Override
public void send(Notification notification) throws NotificationDeliveryException {
String phone = notification.getRecipient().getPhoneNumber();
if (phone == null || phone.isBlank()) {
throw new NotificationDeliveryException("User has no phone number on file");
}
if (ThreadLocalRandom.current().nextInt(100) < 20) {
throw new NotificationDeliveryException("SMS gateway rejected message to " + phone);
}
System.out.printf("[SMS] to=%s body=\"%s\"%n", phone, notification.getMessage());
}
@Override
public ChannelType getType() {
return ChannelType.SMS;
}
} entities/ChannelType.java 7 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.entities;
public enum ChannelType {
EMAIL,
SMS,
PUSH,
IN_APP
} entities/Notification.java 74 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.entities;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public class Notification {
private final String id;
private final User recipient;
private final String title;
private final String message;
private final Priority priority;
private final Map<String, String> metadata;
private Notification(Builder builder) {
this.id = UUID.randomUUID().toString();
this.recipient = builder.recipient;
this.title = builder.title;
this.message = builder.message;
this.priority = builder.priority;
this.metadata = Collections.unmodifiableMap(new HashMap<>(builder.metadata));
}
public String getId() {
return id;
}
public User getRecipient() {
return recipient;
}
public String getTitle() {
return title;
}
public String getMessage() {
return message;
}
public Priority getPriority() {
return priority;
}
public Map<String, String> getMetadata() {
return metadata;
}
public static Builder builder(User recipient, String message) {
return new Builder(recipient, message);
}
public static class Builder {
private final User recipient;
private final String message;
private String title = "";
private Priority priority = Priority.MEDIUM;
private final Map<String, String> metadata = new HashMap<>();
private Builder(User recipient, String message) {
if (recipient == null) {
throw new IllegalArgumentException("recipient is required");
}
if (message == null || message.isBlank()) {
throw new IllegalArgumentException("message is required");
}
this.recipient = recipient;
this.message = message;
}
public Builder title(String title) {
this.title = title;
return this;
}
public Builder priority(Priority priority) {
this.priority = priority;
return this;
}
public Builder metadata(String key, String value) {
this.metadata.put(key, value);
return this;
}
public Notification build() {
return new Notification(this);
}
}
} entities/NotificationStatus.java 6 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.entities;
public enum NotificationStatus {
PENDING,
SENT,
FAILED
} entities/Priority.java 6 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.entities;
public enum Priority {
LOW,
MEDIUM,
HIGH
} entities/User.java 40 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.entities;
import java.util.EnumSet;
import java.util.Set;
public class User {
private final String id;
private final String name;
private final String email;
private final String phoneNumber;
private final String deviceToken;
private final Set<ChannelType> preferredChannels;
public User(String id, String name, String email, String phoneNumber, String deviceToken,
Set<ChannelType> preferredChannels) {
this.id = id;
this.name = name;
this.email = email;
this.phoneNumber = phoneNumber;
this.deviceToken = deviceToken;
this.preferredChannels = preferredChannels == null || preferredChannels.isEmpty()
? EnumSet.noneOf(ChannelType.class)
: EnumSet.copyOf(preferredChannels);
}
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;
}
public Set<ChannelType> getPreferredChannels() {
return preferredChannels;
}
} exception/NotificationDeliveryException.java 9 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.exception;
public class NotificationDeliveryException extends Exception {
public NotificationDeliveryException(String message) {
super(message);
}
public NotificationDeliveryException(String message, Throwable cause) {
super(message, cause);
}
} observer/DeliveryResult.java 29 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.observer;
import com.practice.phonepe.questions.notificationsystem.entities.ChannelType;
import com.practice.phonepe.questions.notificationsystem.entities.Notification;
import com.practice.phonepe.questions.notificationsystem.entities.NotificationStatus;
public class DeliveryResult {
private final Notification notification;
private final ChannelType channel;
private final NotificationStatus status;
private final String failureReason;
public DeliveryResult(Notification notification, ChannelType channel, NotificationStatus status,
String failureReason) {
this.notification = notification;
this.channel = channel;
this.status = status;
this.failureReason = failureReason;
}
public Notification getNotification() {
return notification;
}
public ChannelType getChannel() {
return channel;
}
public NotificationStatus getStatus() {
return status;
}
public String getFailureReason() {
return failureReason;
}
} observer/LoggingNotificationObserver.java 14 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.observer;
import com.practice.phonepe.questions.notificationsystem.entities.NotificationStatus;
public class LoggingNotificationObserver implements NotificationObserver {
@Override
public void onDeliveryResult(DeliveryResult result) {
if (result.getStatus() == NotificationStatus.SENT) {
System.out.printf("[AUDIT] notification=%s channel=%s status=SENT%n",
result.getNotification().getId(), result.getChannel());
} else {
System.out.printf("[AUDIT] notification=%s channel=%s status=FAILED reason=%s%n",
result.getNotification().getId(), result.getChannel(), result.getFailureReason());
}
}
} observer/NotificationObserver.java 4 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.observer;
public interface NotificationObserver {
void onDeliveryResult(DeliveryResult result);
} observer/NotificationPublisher.java 23 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.observer;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* The Subject/Observable in the Observer pattern. Holds no dispatch logic of its own —
* it only maintains the list of interested observers and fans a {@link DeliveryResult}
* out to each of them. {@code NotificationService} depends on this instead of managing
* observers itself.
*/
public class NotificationPublisher {
private final List<NotificationObserver> observers = new CopyOnWriteArrayList<>();
public void subscribe(NotificationObserver observer) {
observers.add(observer);
}
public void unsubscribe(NotificationObserver observer) {
observers.remove(observer);
}
public void publish(DeliveryResult result) {
for (NotificationObserver observer : observers) {
observer.onDeliveryResult(result);
}
}
} service/NotificationService.java 102 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem.service;
import com.practice.phonepe.questions.notificationsystem.channel.NotificationChannel;
import com.practice.phonepe.questions.notificationsystem.channel.NotificationChannelFactory;
import com.practice.phonepe.questions.notificationsystem.channel.RetryableNotificationChannel;
import com.practice.phonepe.questions.notificationsystem.entities.ChannelType;
import com.practice.phonepe.questions.notificationsystem.entities.Notification;
import com.practice.phonepe.questions.notificationsystem.entities.NotificationStatus;
import com.practice.phonepe.questions.notificationsystem.exception.NotificationDeliveryException;
import com.practice.phonepe.questions.notificationsystem.observer.DeliveryResult;
import com.practice.phonepe.questions.notificationsystem.observer.NotificationObserver;
import com.practice.phonepe.questions.notificationsystem.observer.NotificationPublisher;
import java.util.EnumMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* Orchestrator (Singleton). Resolves the effective set of channels for a notification,
* dispatches to each one asynchronously (wrapped with retry), and hands the outcome off
* to {@link NotificationPublisher} (the Observer-pattern Subject) to fan out to observers.
*/
public class NotificationService {
private static final int MAX_ATTEMPTS = 3;
private static final long INITIAL_BACKOFF_MILLIS = 200;
private static volatile NotificationService instance;
private final NotificationChannelFactory channelFactory;
private final ExecutorService executor;
private final NotificationPublisher publisher = new NotificationPublisher();
private final Map<String, Map<ChannelType, NotificationStatus>> statusByNotification = new ConcurrentHashMap<>();
private NotificationService() {
this.channelFactory = new NotificationChannelFactory();
this.executor = Executors.newFixedThreadPool(4);
}
public static NotificationService getInstance() {
if (instance == null) {
synchronized (NotificationService.class) {
if (instance == null) {
instance = new NotificationService();
}
}
}
return instance;
}
public void subscribe(NotificationObserver observer) {
publisher.subscribe(observer);
}
public void unsubscribe(NotificationObserver observer) {
publisher.unsubscribe(observer);
}
/** Send using the recipient's own channel preferences. */
public void send(Notification notification) {
send(notification, notification.getRecipient().getPreferredChannels());
}
/** Send restricted to the intersection of requestedChannels and the recipient's preferences. */
public void send(Notification notification, Set<ChannelType> requestedChannels) {
Set<ChannelType> preferred = notification.getRecipient().getPreferredChannels();
Set<ChannelType> effective = requestedChannels.isEmpty() ? preferred : requestedChannels;
Map<ChannelType, NotificationStatus> statuses = new EnumMap<>(ChannelType.class);
for (ChannelType type : effective) {
if (!preferred.isEmpty() && !preferred.contains(type)) {
continue;
}
statuses.put(type, NotificationStatus.PENDING);
}
statusByNotification.put(notification.getId(), statuses);
for (ChannelType type : statuses.keySet()) {
dispatchAsync(notification, type);
}
}
private void dispatchAsync(Notification notification, ChannelType type) {
NotificationChannel channel = new RetryableNotificationChannel(
channelFactory.get(type), MAX_ATTEMPTS, INITIAL_BACKOFF_MILLIS);
executor.submit(() -> {
try {
channel.send(notification);
recordResult(notification, type, NotificationStatus.SENT, null);
} catch (NotificationDeliveryException e) {
recordResult(notification, type, NotificationStatus.FAILED, e.getMessage());
}
});
}
private void recordResult(Notification notification, ChannelType type, NotificationStatus status,
String failureReason) {
statusByNotification.get(notification.getId()).put(type, status);
publisher.publish(new DeliveryResult(notification, type, status, failureReason));
}
public Map<ChannelType, NotificationStatus> getStatus(String notificationId) {
return statusByNotification.getOrDefault(notificationId, Map.of());
}
public void shutdown() {
executor.shutdown();
}
public void awaitTerminationQuietly(long timeout, TimeUnit unit) {
try {
executor.awaitTermination(timeout, unit);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
} NotificationSystemDemo.java 34 lines GitHub ↗
package com.practice.phonepe.questions.notificationsystem;
import com.practice.phonepe.questions.notificationsystem.entities.ChannelType;
import com.practice.phonepe.questions.notificationsystem.entities.Notification;
import com.practice.phonepe.questions.notificationsystem.entities.Priority;
import com.practice.phonepe.questions.notificationsystem.entities.User;
import com.practice.phonepe.questions.notificationsystem.observer.LoggingNotificationObserver;
import com.practice.phonepe.questions.notificationsystem.service.NotificationService;
import java.util.EnumSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
public class NotificationSystemDemo {
public static void main(String[] args) throws InterruptedException {
NotificationService service = NotificationService.getInstance();
service.subscribe(new LoggingNotificationObserver());
User alice = new User("u1", "Alice", "alice@example.com", "+1-555-0100", "device-token-abc",
EnumSet.of(ChannelType.EMAIL, ChannelType.SMS, ChannelType.PUSH));
User bob = new User("u2", "Bob", "bob@example.com", null, null,
EnumSet.of(ChannelType.IN_APP));
Notification orderShipped = Notification.builder(alice, "Your order #4821 has shipped!")
.title("Order Update")
.priority(Priority.HIGH)
.metadata("orderId", "4821")
.build();
service.send(orderShipped);
Notification promo = Notification.builder(bob, "20% off your next purchase")
.title("Weekend Sale")
.priority(Priority.LOW)
.build();
// Requests EMAIL + IN_APP, but Bob only prefers IN_APP -> only IN_APP is sent.
service.send(promo, Set.of(ChannelType.EMAIL, ChannelType.IN_APP));
service.shutdown();
service.awaitTerminationQuietly(5, TimeUnit.SECONDS);
}
}