Designing a notification system
In this series (15 parts)
- Introduction to low level design
- SOLID principles
- Design patterns: Creational
- Design patterns: Structural
- Design patterns: Behavioral
- Designing a parking lot
- Designing a library management system
- Designing an elevator system
- Designing a hotel booking system
- Designing a ride-sharing model
- Designing a rate limiter
- Designing a logging framework
- Designing a notification system
- API design and contract-first development
- Data modeling for system design
A notification system is one of the most common features in production applications, and one of the most commonly over-simplified. It is not just “send an email.” It is a pipeline that accepts a notification request, resolves the template, selects the delivery channel, dispatches the message, tracks the delivery status, and retries on failure. Each of these steps has design decisions that affect reliability, scalability, and user experience.
For the high-level architecture of notification systems, see notification systems HLD. For the Strategy pattern used here to select channels, refer to design patterns behavioral.
Entity model
The system revolves around five core entities: Notification, Channel, Template, Recipient, and DeliveryStatus.
classDiagram
class Notification {
+String id
+String type
+NotificationPriority priority
+Map~String, String~ payload
+Instant createdAt
+String templateId
+List~String~ recipientIds
}
class Recipient {
+String id
+String userId
+Map~ChannelType, String~ addresses
+List~ChannelType~ preferences
+boolean optedOut
}
class Template {
+String id
+String name
+ChannelType channel
+String subjectTemplate
+String bodyTemplate
+String locale
}
class DeliveryStatus {
+String id
+String notificationId
+String recipientId
+ChannelType channel
+DeliveryState state
+int attemptCount
+Instant lastAttemptAt
+String failureReason
}
class NotificationChannel {
<<interface>>
+send(recipient: Recipient, message: RenderedMessage) DeliveryResult
+getChannelType() ChannelType
}
class EmailChannel {
+send(recipient, message) DeliveryResult
+getChannelType() ChannelType
}
class SmsChannel {
+send(recipient, message) DeliveryResult
+getChannelType() ChannelType
}
class PushChannel {
+send(recipient, message) DeliveryResult
+getChannelType() ChannelType
}
Notification --> Template : uses
Notification --> Recipient : targets
Notification --> DeliveryStatus : tracked by
NotificationChannel <|.. EmailChannel
NotificationChannel <|.. SmsChannel
NotificationChannel <|.. PushChannel
Class diagram showing the notification entity model and the Strategy pattern for delivery channels.
public enum ChannelType { EMAIL, SMS, PUSH, IN_APP }
public enum DeliveryState { PENDING, SENT, DELIVERED, FAILED, RETRYING }
public enum NotificationPriority { LOW, MEDIUM, HIGH, CRITICAL }
public record Notification(
String id,
String type,
NotificationPriority priority,
Map<String, String> payload,
Instant createdAt,
String templateId,
List<String> recipientIds
) {}
public record Recipient(
String id,
String userId,
Map<ChannelType, String> addresses,
List<ChannelType> preferences,
boolean optedOut
) {}
The addresses map holds the destination per channel: an email address for EMAIL, a phone number for SMS, a device token for PUSH. The preferences list is ordered by the user’s preferred channel.
Template rendering
Templates are stored separately from notification logic. This lets product teams update copy without code changes. A simple template engine replaces placeholders with values from the payload map.
public class TemplateEngine {
private final TemplateRepository repository;
public RenderedMessage render(String templateId, Map<String, String> payload) {
Template template = repository.findById(templateId)
.orElseThrow(() -> new TemplateNotFoundException(templateId));
String subject = interpolate(template.subjectTemplate(), payload);
String body = interpolate(template.bodyTemplate(), payload);
return new RenderedMessage(subject, body, template.channel());
}
private String interpolate(String template, Map<String, String> values) {
String result = template;
for (var entry : values.entrySet()) {
result = result.replace(
"{{" + entry.getKey() + "}}",
entry.getValue()
);
}
return result;
}
}
A template for an order confirmation might look like:
Subject: Your order {{orderId}} has been confirmed
Body: Hi {{userName}}, your order of {{itemCount}} items
totaling {{totalAmount}} will arrive by {{estimatedDate}}.
Channel strategy
The Strategy pattern makes channel selection clean. Each channel implements a common interface. The dispatcher selects the right strategy based on the recipient’s preferences and the template’s target channel.
public interface NotificationChannel {
DeliveryResult send(Recipient recipient, RenderedMessage message);
ChannelType getChannelType();
}
public record DeliveryResult(
boolean success,
String messageId,
String failureReason
) {}
public class EmailChannel implements NotificationChannel {
private final SmtpClient smtpClient;
@Override
public DeliveryResult send(Recipient recipient, RenderedMessage message) {
String email = recipient.addresses().get(ChannelType.EMAIL);
if (email == null) {
return new DeliveryResult(false, null, "No email address on file");
}
try {
String messageId = smtpClient.send(email, message.subject(), message.body());
return new DeliveryResult(true, messageId, null);
} catch (SmtpException e) {
return new DeliveryResult(false, null, e.getMessage());
}
}
@Override
public ChannelType getChannelType() { return ChannelType.EMAIL; }
}
The SMS and Push channels follow the same structure, wrapping their respective SDKs (Twilio, Firebase Cloud Messaging, APNs).
The notification dispatcher
The dispatcher orchestrates the full pipeline: resolve recipients, render the template, select the channel, send, and track the result.
public class NotificationDispatcher {
private final Map<ChannelType, NotificationChannel> channels;
private final TemplateEngine templateEngine;
private final RecipientRepository recipientRepo;
private final DeliveryStatusRepository statusRepo;
public void dispatch(Notification notification) {
List<Recipient> recipients = recipientRepo
.findByIds(notification.recipientIds());
for (Recipient recipient : recipients) {
if (recipient.optedOut()) continue;
RenderedMessage message = templateEngine.render(
notification.templateId(), notification.payload()
);
ChannelType selectedChannel = selectChannel(recipient, message);
NotificationChannel channel = channels.get(selectedChannel);
DeliveryResult result = channel.send(recipient, message);
DeliveryStatus status = new DeliveryStatus(
UUID.randomUUID().toString(),
notification.id(),
recipient.id(),
selectedChannel,
result.success() ? DeliveryState.SENT : DeliveryState.FAILED,
1,
Instant.now(),
result.failureReason()
);
statusRepo.save(status);
}
}
private ChannelType selectChannel(Recipient r, RenderedMessage msg) {
for (ChannelType pref : r.preferences()) {
if (r.addresses().containsKey(pref) && channels.containsKey(pref)) {
return pref;
}
}
return ChannelType.EMAIL; // fallback
}
}
Delivery sequence
The full delivery sequence from request to tracking shows how all the pieces connect.
sequenceDiagram
participant S as Source Service
participant Q as Message Queue
participant D as Dispatcher
participant T as TemplateEngine
participant C as Channel (Email/SMS/Push)
participant DB as DeliveryStatus DB
S->>Q: publish NotificationRequest
Q->>D: consume NotificationRequest
D->>D: resolve recipients
D->>T: render(templateId, payload)
T-->>D: RenderedMessage
D->>D: select channel by preference
D->>C: send(recipient, message)
C-->>D: DeliveryResult(success/failure)
D->>DB: save DeliveryStatus
alt delivery failed
D->>Q: re-enqueue with retry metadata
end
Sequence diagram showing the notification delivery pipeline from source service to delivery tracking.
Retry logic
Notifications fail. The email server is temporarily unavailable. The push notification token expired. The SMS provider returns a 503. Retries are essential, but they need to be bounded and backed off.
public class RetryPolicy {
private final int maxAttempts;
private final Duration initialDelay;
private final double backoffMultiplier;
public RetryPolicy(int maxAttempts, Duration initialDelay, double multiplier) {
this.maxAttempts = maxAttempts;
this.initialDelay = initialDelay;
this.backoffMultiplier = multiplier;
}
public boolean shouldRetry(DeliveryStatus status) {
return status.attemptCount() < maxAttempts
&& isRetryableFailure(status.failureReason());
}
public Duration getNextDelay(int attemptCount) {
double multiplier = Math.pow(backoffMultiplier, attemptCount - 1);
return initialDelay.multipliedBy((long) multiplier);
}
private boolean isRetryableFailure(String reason) {
if (reason == null) return false;
return reason.contains("timeout")
|| reason.contains("503")
|| reason.contains("temporarily unavailable");
}
}
The backoff schedule looks like this for 5 attempts with a 1-second initial delay and 2x multiplier: 1s, 2s, 4s, 8s, 16s. After all attempts are exhausted, mark the delivery as permanently failed and optionally alert an operator.
stateDiagram-v2 [*] --> PENDING PENDING --> SENT : delivery success PENDING --> RETRYING : delivery failed, retries left RETRYING --> SENT : retry success RETRYING --> RETRYING : retry failed, retries left RETRYING --> FAILED : max retries exceeded SENT --> DELIVERED : delivery confirmed FAILED --> [*] DELIVERED --> [*]
State diagram for notification delivery status transitions.
Priority and rate limiting
Not all notifications are equal. A two-factor authentication code must arrive in seconds. A weekly digest can wait hours. Priority affects queue ordering and rate limiting.
public class PriorityNotificationQueue {
private final PriorityBlockingQueue<NotificationTask> queue;
public PriorityNotificationQueue() {
this.queue = new PriorityBlockingQueue<>(100,
Comparator.comparing(t -> t.notification().priority().ordinal())
);
}
public void enqueue(Notification notification) {
queue.offer(new NotificationTask(notification, Instant.now()));
}
public NotificationTask dequeue() throws InterruptedException {
return queue.take();
}
}
CRITICAL priority notifications bypass the queue entirely and dispatch synchronously. HIGH goes to the front of the queue. LOW and MEDIUM follow FIFO within their priority band.
Channel fallback
If the preferred channel fails after all retries, try the next channel in the recipient’s preference list. This is particularly important for transactional notifications.
public void dispatchWithFallback(Notification notification, Recipient recipient) {
RenderedMessage message = templateEngine.render(
notification.templateId(), notification.payload()
);
for (ChannelType channelType : recipient.preferences()) {
if (!channels.containsKey(channelType)) continue;
if (!recipient.addresses().containsKey(channelType)) continue;
NotificationChannel channel = channels.get(channelType);
DeliveryResult result = channel.send(recipient, message);
if (result.success()) {
saveStatus(notification, recipient, channelType, DeliveryState.SENT);
return;
}
}
saveStatus(notification, recipient, null, DeliveryState.FAILED);
}
What comes next
Notifications flow through APIs, and those APIs need well-defined contracts. The next article on API design and contract-first development covers how to design the request and response schemas, error envelopes, versioning strategies, and pagination patterns that make your notification API (and every other API) predictable and evolvable.