Post

System Design — Notification Service (Email, SMS, Push) with Spring Boot

Design a multi-channel notification service with Spring Boot and Kafka. Covers email (JavaMail + Thymeleaf templates), SMS, push notifications, priority queues, retry with dead letter topics, template rendering, and scaling strategies.

System Design — Notification Service (Email, SMS, Push) with Spring Boot

The Problem

Almost every production system needs notifications. Users expect emails on sign-up, SMS for OTPs, push notifications for real-time updates. But building a notification service that handles multiple channels, templates, priorities, retries, and scale is far from trivial.

This is also a classic system design interview question — and one that reveals whether you understand event-driven architecture, the strategy pattern, and failure handling in distributed systems.

In this post, we’ll design and implement a multi-channel notification service using Spring Boot 3.5, Apache Kafka, and JavaMail with Thymeleaf templates.


Requirements

Functional Requirements

#Requirement
1Send notifications through Email, SMS, and Push channels
2Support templates with variable substitution
3Priority-based processing (HIGH before LOW)
4Retry failed notifications with exponential backoff
5Dead letter queue for permanently failed notifications
6Unified API — single endpoint, channel specified in payload

Non-Functional Requirements

#Requirement
1Asynchronous processing — API returns immediately
2At-least-once delivery guarantee
3Horizontally scalable consumers per channel
4Rate limiting per channel (email providers throttle)
5Observability — log every send attempt and outcome

High-Level Architecture

graph LR
    A[Client Service] -->|POST /api/notifications| B[Notification API]
    B -->|Publish| C[Kafka Topic]
    C --> D[Consumer Group]
    D --> E{Channel Router}
    E -->|EMAIL| F[Email Channel]
    E -->|SMS| G[SMS Channel]
    E -->|PUSH| H[Push Channel]
    F --> I[SMTP / Mailhog]
    G --> J[Twilio API]
    H --> K[FCM / APNs]
    D -->|Retry exhausted| L[Dead Letter Topic]

diagram

The architecture decouples the API from delivery. The producer returns 202 Accepted immediately, and Kafka handles reliable delivery to the appropriate channel consumer.


Channel Abstraction — Strategy Pattern

The Strategy pattern is perfect here. Each channel implements the same interface, and the consumer routes to the correct implementation at runtime.

classDiagram
    class NotificationChannel {
        <<interface>>
        +getChannel() Channel
        +send(NotificationRequest) void
    }

    class EmailChannel {
        -JavaMailSender mailSender
        -TemplateEngine templateEngine
        +getChannel() Channel
        +send(NotificationRequest) void
    }

    class SmsChannel {
        +getChannel() Channel
        +send(NotificationRequest) void
    }

    class PushChannel {
        +getChannel() Channel
        +send(NotificationRequest) void
    }

    NotificationChannel <|.. EmailChannel
    NotificationChannel <|.. SmsChannel
    NotificationChannel <|.. PushChannel

diagram

The Interface

1
2
3
4
5
6
public interface NotificationChannel {

    NotificationRequest.Channel getChannel();

    void send(NotificationRequest request);
}

Channel Router in Consumer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
public class NotificationConsumer {

    private final Map<Channel, NotificationChannel> channelHandlers;

    public NotificationConsumer(List<NotificationChannel> channels) {
        this.channelHandlers = channels.stream()
            .collect(Collectors.toMap(
                NotificationChannel::getChannel,
                Function.identity()
            ));
    }

    @KafkaListener(topics = "${app.kafka.topic}")
    public void consume(NotificationRequest request) {
        NotificationChannel handler = channelHandlers.get(request.channel());
        handler.send(request);
    }
}

Spring autowires all NotificationChannel implementations as a List, and we build a lookup map. Adding a new channel is just implementing the interface — zero changes to existing code (Open/Closed Principle).


Email with Thymeleaf Templates

Email is the most complex channel because of HTML rendering. We use Thymeleaf as a server-side template engine to produce rich HTML emails.

Template (welcome.html)

1
2
3
4
5
<div class="content">
    <p>Hi <span th:text="${name}">User</span>,</p>
    <p>Welcome to our platform!</p>
    <a th:href="${activationLink}" class="button">Activate Account</a>
</div>

Email Channel Implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Component
public class EmailChannel implements NotificationChannel {

    private final JavaMailSender mailSender;
    private final TemplateEngine templateEngine;

    @Override
    public void send(NotificationRequest request) {
        String htmlContent = renderTemplate(request.templateName(), request.variables());

        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
        helper.setTo(request.recipient());
        helper.setSubject(resolveSubject(request.templateName()));
        helper.setText(htmlContent, true);

        mailSender.send(message);
    }

    private String renderTemplate(String templateName, Map<String, String> variables) {
        Context context = new Context();
        if (variables != null) {
            variables.forEach(context::setVariable);
        }
        return templateEngine.process(templateName, context);
    }
}

For local testing, we use Mailhog (port 1025 for SMTP, port 8025 for web UI).


SMS — Twilio Integration Shape

In production, you’d integrate with Twilio or AWS SNS. The implementation shape:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class SmsChannel implements NotificationChannel {

    @Override
    public void send(NotificationRequest request) {
        // Production: Twilio API call
        // TwilioRestClient.create(accountSid, authToken)
        //     .messages()
        //     .create(new PhoneNumber(request.recipient()),
        //             new PhoneNumber(fromNumber),
        //             buildMessageBody(request));

        log.info("[SMS] Sending to {}: {}", request.recipient(), buildMessageBody(request));
    }
}

We simulate the SMS channel with logging. The contract is the same — swap in the real Twilio client for production.


Push — FCM Integration Shape

Similarly, push notifications would integrate with Firebase Cloud Messaging:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component
public class PushChannel implements NotificationChannel {

    @Override
    public void send(NotificationRequest request) {
        // Production: FCM call
        // FirebaseMessaging.getInstance().send(
        //     Message.builder()
        //         .setToken(request.recipient())
        //         .setNotification(Notification.builder()
        //             .setTitle(resolveTitle(request))
        //             .setBody(resolveBody(request))
        //             .build())
        //         .build());

        log.info("[PUSH] Sending to device: {}", request.recipient());
    }
}

Priority Queues with Kafka

Different notifications have different urgency levels. An OTP should arrive within seconds. A marketing email can wait.

Approach: Separate Topics by Priority

graph TD
    A[API] --> B{Priority?}
    B -->|HIGH| C[notifications-high]
    B -->|MEDIUM| D[notifications-medium]
    B -->|LOW| E[notifications-low]
    C --> F[Consumer Pool - 8 threads]
    D --> G[Consumer Pool - 4 threads]
    E --> H[Consumer Pool - 2 threads]

diagram

PriorityTopicConsumer ThreadsMax Latency
HIGHnotifications-high8< 5 seconds
MEDIUMnotifications-medium4< 30 seconds
LOWnotifications-low2< 5 minutes

By allocating more consumer threads to high-priority topics, we ensure OTPs and security alerts are processed before marketing emails.


Retry Strategy with Dead Letter Topics

Notifications fail. SMTP servers go down, Twilio rate-limits, FCM tokens expire. We need retry with backoff.

Spring Kafka’s @RetryableTopic handles this elegantly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RetryableTopic(
    attempts = "3",
    backoff = @Backoff(delay = 1000, multiplier = 2.0)
)
@KafkaListener(topics = "${app.kafka.topic}")
public void consume(NotificationRequest request) {
    channelHandlers.get(request.channel()).send(request);
}

@DltHandler
public void handleDlt(NotificationRequest request) {
    log.error("Notification permanently failed: {}", request);
    // Alert ops, persist to dead letter table
}

Retry Flow

sequenceDiagram
    participant Kafka
    participant Consumer
    participant Channel
    participant DLT as Dead Letter Topic

    Kafka->>Consumer: Deliver message
    Consumer->>Channel: send()
    Channel-->>Consumer: Exception!
    Note over Consumer: Attempt 1 failed
    Consumer->>Kafka: Retry after 1s
    Kafka->>Consumer: Redeliver
    Consumer->>Channel: send()
    Channel-->>Consumer: Exception!
    Note over Consumer: Attempt 2 failed
    Consumer->>Kafka: Retry after 2s
    Kafka->>Consumer: Redeliver
    Consumer->>Channel: send()
    Channel-->>Consumer: Exception!
    Note over Consumer: Attempt 3 failed
    Consumer->>DLT: Move to DLT

diagram


Template Engine

The template system uses a convention: template name maps to a file in src/main/resources/templates/.

Template NameFileUse Case
welcomewelcome.htmlNew user registration
otpotp.htmlVerification codes
password-resetpassword-reset.htmlPassword recovery
order-confirmationorder-confirmation.htmlE-commerce orders

Variables are passed as a Map<String, String> and rendered at send time:

1
2
3
4
5
6
7
8
9
10
{
  "recipient": "user@example.com",
  "channel": "EMAIL",
  "templateName": "welcome",
  "variables": {
    "name": "Anupam",
    "activationLink": "https://example.com/activate?token=abc123"
  },
  "priority": "HIGH"
}

Rate Limiting per Channel

Email providers (SES, SendGrid) enforce rate limits. Twilio has per-second SMS limits. We need per-channel rate limiting.

ChannelTypical Rate LimitStrategy
Email (SES)14/secondToken bucket
SMS (Twilio)1/second per numberQueue with delay
Push (FCM)500/secondBatch sends

Implementation approach with Resilience4j:

1
2
3
4
5
6
7
8
@RateLimiter(name = "email-sender", fallbackMethod = "rateLimited")
public void send(NotificationRequest request) {
    // send email
}

private void rateLimited(NotificationRequest request, Exception ex) {
    throw new RateLimitExceededException("Email rate limit exceeded, will retry");
}

Scaling — Consumer Groups per Channel

graph TD
    A[Kafka Topic: notifications] --> B[Partition 0]
    A --> C[Partition 1]
    A --> D[Partition 2]
    A --> E[Partition 3]

    B --> F[Consumer Instance 1]
    C --> F
    D --> G[Consumer Instance 2]
    E --> G

    F --> H{Route by Channel}
    G --> H
    H --> I[Email Pool]
    H --> J[SMS Pool]
    H --> K[Push Pool]

diagram

Scaling strategies:

  1. Horizontal scaling — Add more consumer instances (Kafka rebalances partitions)
  2. Channel isolation — Separate topics per channel so email failures don’t block SMS
  3. Partition by priority — Use message key = priority + channel for even distribution
  4. Batch processing — FCM supports sending to 500 devices in one API call

Common Problems

ProblemCauseSolution
Duplicate notificationsAt-least-once deliveryIdempotency key per notification
Email in spam folderMissing SPF/DKIM/DMARCConfigure DNS records properly
Consumer lagSlow channel (SMTP timeout)Increase partitions and consumers
Template not foundTypo in template nameValidate template exists at publish time
Rate limit exceededToo many sends per secondPer-channel rate limiting
Retry stormAll retries hit at same timeAdd jitter to backoff
Lost notificationsConsumer crash before commitUse manual offset commit after send
Memory pressureLarge email attachmentsStream attachments, limit payload size

Full Working Example

The complete source code is available on GitHub:

spring-boot-notification-service

To run locally:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Start Kafka + Mailhog
docker compose up -d

# Run the application
./mvnw spring-boot:run

# Send an email notification
curl -X POST http://localhost:8080/api/notifications \
  -H "Content-Type: application/json" \
  -d '{
    "recipient": "user@example.com",
    "channel": "EMAIL",
    "templateName": "welcome",
    "variables": {"name": "Anupam", "activationLink": "https://example.com/activate"},
    "priority": "HIGH"
  }'

# Check Mailhog UI for the email
open http://localhost:8025

References

This post is licensed under CC BY 4.0 by the author.