Notification service
Design a service that sends SMS, email and push messages when events happen, respecting user preferences, filling templates, and retrying failed deliveries without sending twice.
When an order is placed, shipped or delivered, users should get a message by SMS, email or push, based on their preferences. Design the notification module: channels, templates, preferences and retries. New channels like WhatsApp will come later.
Questions to ask first
- What triggers a notification? Assume domain events, such as OrderPlaced, published inside the same application.
- Who chooses the channel? Assume the user's preferences per category (orders, offers), with a default per event.
- Can users turn off everything? Assume they can turn off marketing, but not security messages such as OTPs.
- How do we handle a provider failure? Assume up to 3 attempts with exponential backoff, then a dead-letter list for review.
- Do messages need to be in the user's language? Assume templates per language, with English as the fallback.
- Must the order flow wait for the message? Assume no. Sending is asynchronous and never fails the order.
Requirements
- Listen to domain events and turn each into a notification for a user.
- Pick channels from the user's preferences for that category.
- Render a template per channel and language, filling placeholders like the order id.
- Send through a sender per channel: SMS, email and push.
- Retry transient failures with backoff, and record final failures.
- Add a new channel without changing the service's core code.
Out of scope
- The SMS, email and push providers themselves.
- Bulk marketing campaigns to millions of users.
- Read receipts and analytics dashboards.
Entities
- EventBus -> subscribe(listener), publish(event); EventListener -> onEvent(event)
- NotificationService implements EventListener -> onEvent(), deliver()
- Notification: userId, category, templateId, params
- Channel: SMS, EMAIL, PUSH
- ChannelSender (interface) -> channel(), send(message); SmsSender, EmailSender, PushSender
- SenderFactory -> forChannel(channel)
- PreferenceStore -> channelsFor(userId, category); TemplateService -> render(); RetryPolicy -> nextDelay(attempt)
Classes in the model design
- EventBus (class)
- EventListener (interface)
- NotificationService (class)
- Notification (class)
- Message (class)
- Channel (enum)
- ChannelSender (interface)
- SmsSender (class)
- EmailSender (class)
- PushSender (class)
- SenderFactory (class)
- PreferenceStore (class)
- TemplateService (class)
- RetryPolicy (class)
The main use case, step by step
- The order module places order 5521 for Arjun and calls `EventBus.publish(new OrderPlaced(...))`. The order flow does not know who is listening.
- `NotificationService` subscribed at startup, so its `onEvent` runs. It builds a `Notification` with category ORDERS, template 'order_placed' and the order id as a parameter.
- It asks `PreferenceStore.channelsFor(arjun, ORDERS)` and gets SMS and PUSH. Arjun turned email off for orders.
- For each channel, `TemplateService.render` fills the right template in Arjun's language and returns an immutable `Message`. The SMS version is short; the push version has a title.
- Each message is handed to a worker thread. `SenderFactory.forChannel(SMS)` returns the `SmsSender`, which calls the provider. The order request has already returned.
- The SMS provider times out. `RetryPolicy.nextDelay(1)` says 'wait about 1 second', so the scheduler tries again. The second attempt succeeds. If all 3 attempts fail, the message goes to a dead-letter list for review.
Patterns used
- Observer: EventBus keeps a list of EventListener objects; NotificationService is one of them.. The order module publishes 'order placed' and does not know about SMS or email. New listeners, such as analytics, subscribe without changing the order code.
- Strategy: ChannelSender with SmsSender, EmailSender and PushSender; RetryPolicy for the delay rule.. Each channel has different provider calls, limits and error types. The service treats them all the same way through one interface, so adding WhatsApp means adding one class.
- Factory: SenderFactory builds a map from Channel to ChannelSender once and returns the right sender.. The service never writes `new SmsSender()` or a switch on channel. Tests pass a factory with fake senders, and a new channel is registered in one place.
Key code
Channels, senders and the factory
public enum Channel { SMS, EMAIL, PUSH }
public record Message(String notificationId, Channel channel, String to, String subject, String body) {}
public class DeliveryException extends Exception {
private final boolean transientFailure; // timeouts yes, invalid phone number no
public DeliveryException(String msg, boolean transientFailure) {
super(msg);
this.transientFailure = transientFailure;
}
public boolean isTransient() { return transientFailure; }
}
public interface ChannelSender {
Channel channel();
void send(Message message) throws DeliveryException;
}
public final class SenderFactory {
private final Map<Channel, ChannelSender> senders = new EnumMap<>(Channel.class);
public SenderFactory(List<ChannelSender> all) {
for (ChannelSender s : all) senders.put(s.channel(), s);
}
public ChannelSender forChannel(Channel c) {
ChannelSender s = senders.get(c);
if (s == null) throw new IllegalArgumentException("No sender registered for " + c);
return s;
}
}
public final class RetryPolicy {
private final int maxAttempts;
private final Duration baseDelay;
public RetryPolicy(int maxAttempts, Duration baseDelay) {
this.maxAttempts = maxAttempts;
this.baseDelay = baseDelay;
}
/** Exponential backoff with jitter: about 1s, then 2s, then 4s, until maxAttempts have run. */
public Optional<Duration> nextDelay(int attempt) {
if (attempt >= maxAttempts) return Optional.empty();
long ms = baseDelay.toMillis() << (attempt - 1);
long jitter = ThreadLocalRandom.current().nextLong(ms / 2 + 1);
return Optional.of(Duration.ofMillis(ms + jitter));
}
}
The service: listen, fan out, retry
public final class NotificationService implements EventListener {
private final SenderFactory senders;
private final PreferenceStore preferences;
private final TemplateService templates;
private final RetryPolicy retry;
private final ScheduledExecutorService scheduler;
private final Set<String> delivered = ConcurrentHashMap.newKeySet(); // idempotency keys
private final Queue<Message> deadLetters = new ConcurrentLinkedQueue<>();
public NotificationService(SenderFactory senders, PreferenceStore preferences, TemplateService templates,
RetryPolicy retry, ScheduledExecutorService scheduler) {
this.senders = senders; this.preferences = preferences; this.templates = templates;
this.retry = retry; this.scheduler = scheduler;
}
@Override
public void onEvent(DomainEvent event) {
send(Notification.from(event)); // maps event type to template and category
}
public void send(Notification n) {
for (Channel c : preferences.channelsFor(n.userId(), n.category())) {
Message m = templates.render(n, c, preferences.localeOf(n.userId()));
scheduler.execute(() -> deliver(m, 1)); // never block the caller
}
}
private void deliver(Message m, int attempt) {
String key = m.notificationId() + ":" + m.channel();
if (delivered.contains(key)) return; // a duplicate event for a message already sent
try {
senders.forChannel(m.channel()).send(m);
delivered.add(key);
} catch (DeliveryException e) {
Optional<Duration> delay = e.isTransient() ? retry.nextDelay(attempt) : Optional.empty();
if (delay.isPresent()) {
scheduler.schedule(() -> deliver(m, attempt + 1), delay.get().toMillis(), TimeUnit.MILLISECONDS);
} else {
deadLetters.add(m);
}
}
}
}
Deep dives
Choosing the sender for a channel
Weak: A switch (channel) inside NotificationService with provider code in each case. Adding WhatsApp edits the service, and a test of the email path needs the SMS client too.
Good: A ChannelSender interface with one class per channel, and a SenderFactory that maps each Channel to its sender. The service asks the factory and calls send. A test passes fake senders that record messages.
Great: Do the same, and let senders register themselves: the factory takes a list of all ChannelSender objects and keys them by channel(). A new channel is one class plus one enum value. For one channel with two providers (two SMS vendors), wrap them in a FailoverSmsSender that implements the same interface and tries the backup when the main one fails.
Trade-off: The enum still needs editing for a new channel. A string key avoids that but loses type checking and allows typos such as 'EMIAL'. For a fixed, small set of channels, the enum is the safer choice.
Retries without spamming the user
Weak: Retry in a loop with Thread.sleep(1000) on the thread that placed the order. A slow SMS provider now slows checkout. A retry after a timeout can also send the same OTP twice, because the first attempt may have worked.
Good: Send on a worker pool and retry with exponential backoff (1 second, 2 seconds, 4 seconds), up to a maximum number of attempts. Move final failures to a dead-letter list so someone can look at them.
Great: Do the same, and add jitter (a small random extra delay) so a provider outage does not cause all retries to fire at once. Retry only transient errors such as timeouts, never 'invalid phone number'. Give each message an idempotency key (notification id plus channel) and pass it to the provider, so a duplicate attempt is dropped. Persist pending messages so a restart does not lose them.
Trade-off: The in-memory set of delivered keys shown in the code works in one process only and grows forever. A real system keeps keys in a store with an expiry time, such as one day. Exactly-once delivery across a network is not possible; the aim is at least once, with duplicates removed.
Preferences and templates
Weak: Hard-code 'Your order {id} is placed' in the service and send it on every channel. Users who turned off SMS still get SMS, the text is English only, and changing a word needs a deploy.
Good: A PreferenceStore returns the channels a user allows for a category, and a TemplateService keeps one template per event, channel and language with placeholders. Missing languages fall back to English.
Great: Do the same, and give each category a rule. Security messages, such as OTPs, always go out. Order updates follow user choices. Marketing needs an explicit opt-in and respects quiet hours, such as 10 pm to 8 am. Check templates when they are saved: every placeholder must have a value, and the SMS text must fit its length limit.
Trade-off: Rich preference rules make support harder ('why did I not get the message?'). Log, for each notification, which channels were chosen and why, so support can answer from the log rather than guess.
Now add one more feature
- Now add WhatsApp as a channel. What changes? Add WHATSAPP to `Channel`, write a `WhatsAppSender implements ChannelSender`, and register it with the factory. Add templates for it. The service, retries and preferences do not change.
- Now send a daily digest of offers instead of one message per offer. What changes? Marketing events go to a `DigestCollector` (another listener) that stores them per user. A scheduled job renders one digest template per user and calls `NotificationService.send`.
- Now the order module runs as a separate service. What changes? The in-process `EventBus` becomes a message queue such as Kafka. The notification service consumes from it. The observer idea stays, but delivery is now at least once, so idempotency keys matter more.
- Now limit each user to 5 marketing messages a day. What changes? Add a `RateLimiter` check before rendering, keyed by user and category. Over the limit, the notification is dropped or moved to the next digest.
What each level is expected to show
SDE-1: A ChannelSender interface with SMS, email and push classes, a factory that picks the sender, user preferences, and simple templates with placeholders. Show a main method where an order event sends two messages. Explain why the service has no switch on channel.
SDE-2: Everything at SDE-1, plus observer wiring from domain events, asynchronous sending, retries with backoff and jitter only for transient errors, idempotency keys, a dead-letter list, and category rules for preferences. Explain how the design changes when events come from a message queue.
Common mistakes
- A switch on channel type inside the service, so every new channel edits it.
- Sending on the request thread, so a slow provider slows down checkout.
- Retrying every error, including ones that will never succeed, such as a wrong email address.
- Retrying without an idempotency key, so the user gets the same OTP twice.
- Ignoring user preferences, or letting users turn off security messages.
- Calling `new SmsSender()` inside the service, so tests cannot replace it with a fake.