Search…
Low Level Design · Part 13

Designing a notification system

In this series (15 parts)
  1. Introduction to low level design
  2. SOLID principles
  3. Design patterns: Creational
  4. Design patterns: Structural
  5. Design patterns: Behavioral
  6. Designing a parking lot
  7. Designing a library management system
  8. Designing an elevator system
  9. Designing a hotel booking system
  10. Designing a ride-sharing model
  11. Designing a rate limiter
  12. Designing a logging framework
  13. Designing a notification system
  14. API design and contract-first development
  15. 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.

Start typing to search across all content
navigate Enter open Esc close