logo

Spring Boot Notification Service: Event-Driven Architecture with Kafka

Notifications appear deceptively simple.

  • A user registers. Send an email.
  • A password reset occurs. Send an OTP.
  • A payment succeeds. Send a confirmation message.

Many applications implement these workflows synchronously because it feels natural to send notifications immediately after business operations are complete.

Unfortunately, this approach introduces hidden coupling.

  • If the email provider becomes unavailable, user registration may fail.
  • If SMS delivery slows down, password reset requests become slow.
  • If notification of volume spikes, unrelated business services begin experiencing increased latency.

A dedicated Notification Service solves this problem by separating communication concerns from business workflows.

Rather than delivering notifications directly, business services publish events. The Notification Service consumes those events and handles delivery independently.

This architecture improves reliability, scalability, and fault isolation while allowing notification infrastructure to evolve independently of business services.

By Bharat Chaudhari July 16, 2026

Why Notifications Should Be Asynchronous

One of the most common anti-patterns in distributed systems is synchronous notification delivery.

Example:

  • User Registration → Save User → Send Email → Return Response

This appears simple. However, the user experience is now dependent on:

  • Mail provider availability
  • Mail provider latency
  • Network reliability
  • Third-party infrastructure

If any of this fail, registration becomes slower or completely unavailable.

A better approach is:

				
					User Registration → Save User → Publish Event → Return Response 
                                      ↓ 
                             Notification Service 
                                      ↓ 
                                  Send Email 
				
			

The user receives an immediate response while notification delivery occurs independently.

This dramatically improves resilience and user experience.

Why We Chose Kafka

Several approaches exist for asynchronous communication. Common options include:
  • REST callbacks
  • RabbitMQ
  • Kafka
  • AWS SNS/SQS
  • Google Pub/Sub
For this platform, Kafka was selected because it provides:
  • High throughput
  • Durable event storage
  • Consumer scalability
  • Replay capabilities
  • Fault tolerance
Most importantly, Kafka allows services to remain decoupled while supporting reliable event delivery at scale.

Notification Service Responsibilities

The Notification Service owns communication delivery.

Its responsibilities include:

  • Email delivery
  • OTP delivery
  • Template rendering
  • Notification audit logging
  • Channel selection
  • Retry handling

It does not own:

  • User registration
  • Authentication
  • Payment processing
  • Business workflows

Business services publish events. Notification Service handles communication.

This separation creates cleaner service boundaries and prevents notification concerns from leaking into business services.

Notification Flow

				
					User Service  
Payment Service  
Auth Service  
 
        │  
        │  
        ▼  
 
       Kafka  
 
        │  
        ▼  
 
Notification Service  
 
        │  
 
 ┌──────┼─────────┐  
 ▼      ▼         ▼  
 
Email   SMS    Push Notification  
 
        │  
 
        ▼  
 
      Users 
				
			

Event-Driven architecture in practice

Event-driven systems differ fundamentally from request-response architectures.

In a request-response model:

  • Service A waits for Service B to complete.

In an event-driven model:

  • Service A publishes an event and continues. Consumers react independently.

Benefits include:

  • Reduced Coupling: Services no longer depend on notification infrastructure.
  • Better Scalability: Consumers scale independently from producers.
  • Improved Reliability: Temporary failures do not immediately impact user-facing workflows.
  • Easier Evolution: New consumers can subscribe without modifying existing services.

Architecture & Tech Stack

Our Notification Service is designed for performance and scale on a reliable open-source foundation:

  • Language & Framework: Java 21 & Spring Boot 4.0.1
  • Event-Driven Messaging: Apache Kafka for decoupling upstream services from notification delivery.
  • Database: PostgreSQL for production persistence, managed via Flyway migrations.
  • Providers: Deep integrations with industry standards including Twilio (SMS), Firebase/FCM (Push), and Gmail (Email).

High-Level Flow

The system leverages an event-driven flow where messages from Kafka are dynamically routed to their appropriate dispatch strategies:

Notification diagram

Key Features

1. Multi-Channel Support & Event-Driven

A single Kafka payload can trigger notifications across SMS, Email, and Push simultaneously. Upstream services publish a NotificationMessage to the notification.send Kafka topic. This asynchronous approach prevents sluggish notification delivery from bottlenecking critical business flows.

The Kafka Listener Endpoint

Under the hood, we consume these events gracefully using Spring Kafka. If a critical failure happens, the exception is propagated so Spring Kafka can automatically handle retries:

				
					@Component 
@Slf4j 
@RequiredArgsConstructor 
public class NotificationConsumer { 
 
    private final NotificationService notificationService; 
 
    @KafkaListener( 
            topics = "${kafka.topics.notification:notification-events}", 
            groupId = "${spring.kafka.consumer.group-id}" 
    ) 
    public void consume(NotificationMessage message) { 
        log.info("Received notification event: {}", message.getNotificationId()); 
        try { 
            notificationService.processNotification(message); 
            log.info("Successfully processed notification: {}", message.getNotificationId()); 
        } catch (Exception e) { 
            log.error("Error processing: {}", message.getNotificationId(), e); 
            throw e; // Let Spring Kafka handle retries and DLQ routing 
        } 
    } 
}
				
			

Delivery Guarantees

One reason Kafka is particularly effective for notifications is its durability model.

Events remain available even if consumers become temporarily unavailable.

For example:

  • User Service → Publishes Event
  • Notification Service → Offline
  • Kafka → Retains Event
  • Notification Service → Returns
  • Event Processed Successfully

This significantly improves resilience compared to direct service-to-service communication.

2. High-Performance Parallel Dispatch

Wait times are minimized via parallel processing. If a single notification needs to go out through Email and Push, the NotificationService uses CompletableFuture to process both channels concurrently.

Here is a snippet from our NotificationService showcasing this parallel dispatch:

				
					public void processNotification(NotificationMessage message) { 
    log.info("Processing notification: {} for event: {}", message.getNotificationId(), message.getEventKey()); 
 
    // 1. Create Notification Log (Status: PROCESSING) 
    NotificationLog savedLog = logRepository.save(new NotificationLog(message)); 
 
    // 2. Dispatch to channels in parallel 
    List<CompletableFuture<Void>> futures = message.getChannels().stream() 
            .map(channel -> processChannel(channel, message, savedLog)) 
            .filter(Optional::isPresent) 
            .map(Optional::get) 
            .toList(); 
 
    // 3. Wait for all channels to complete 
    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); 
 
    // 4. Update overall status to COMPLETED 
    savedLog.setStatus(NotificationStatus.COMPLETED.name()); 
    logRepository.save(savedLog); 
}
				
			

3. Dynamic Template Management

We’ve built an API-driven Template Service directly into the component. Hardcoding message copy is an anti-pattern.

Services pass an eventKey and a payload. The service automatically looks up the active template and uses Regex to substitute {{placeholders}} with real data from the payload:

				
					// Inside TemplateService.java 
public String processTemplate(String template, Map<String, Object> payload) { 
    if (template == null || payload == null) return template; 
 
    Matcher matcher = Pattern.compile("\\{\\{(\\w+)}}").matcher(template); 
    StringBuilder result = new StringBuilder(); 
 
    while (matcher.find()) { 
        String placeholder = matcher.group(1); 
        Object value = payload.get(placeholder); 
        String replacement = value != null ? value.toString() : ""; 
        matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); 
    } 
    matcher.appendTail(result); 
    return result.toString(); 
}
				
			

Template API Example

You can dynamically create and manage templates using our REST API:

POST /api/templates

				
					{ 
  "eventKey": "WELCOME_USER", 
  "channel": "EMAIL", 
  "name": "Welcome Email", 
  "subject": "Welcome to Our Platform!", 
  "body": "Hi {{name}}, thanks for joining. Your current tier is {{tier}}.", 
  "active": true 
} 
				
			

Retry Strategy

Notification delivery is not always successful. Common failures include:
  • Mail provider outages
  • Network failures
  • Temporary DNS issues
  • SMS provider downtime
The Notification Service should treat delivery failures as expected events rather than exceptional situations. A robust retry strategy typically includes:
  1. Initial delivery attempt.
  2. Retries with backoff.
  3. Dead-letter handling.
  4. Operational alerts.
This prevents transient failures from becoming user-visible problems.

Dead Letter Queues (DLQ)

Not every event can be delivered successfully.

Examples:

  • Invalid email address
  • Deleted user
  • Provider rejection

Repeated retries may never succeed. Dead Letter Queues provide a mechanism for isolating permanently failing events.

Rather than blocking processing, problematic messages are moved to a separate queue where they can be investigated independently.

This prevents a small number of failures from impacting overall system throughput.

Multi-Channel Notification Strategy

Many organizations eventually require multiple communication channels.

Examples include:

  • Email
  • SMS
  • Push Notifications
  • WhatsApp
  • In-App Messaging

The Notification Service should be designed around channels rather than providers.

Business services publish:

PasswordResetRequested

The Notification Service decides:

  • Email
  • SMS
  • Push

This creates greater flexibility and reduces coupling between business workflows and communication infrastructure.

Database Migrations & Audit Logging

The entire lifecycle of a notification translates directly into our PostgreSQL database allowing for granular status tracking (PROCESSING, COMPLETED, FAILED) and a clear audit trail.

Our application schema is perfectly version-controlled via Flyway. Here is the core SQL migration (V1__init_schema.sql) demonstrating exactly what data is captured:

				
					CREATE TABLE notification_logs ( 
    id UUID PRIMARY KEY, 
    user_id VARCHAR(255) NOT NULL, 
    channels JSONB, 
    status VARCHAR(50), 
    created_at TIMESTAMP, 
    updated_at TIMESTAMP 
); 
 
CREATE TABLE notification_delivery ( 
    id UUID PRIMARY KEY, 
    notification_id UUID references notification_logs(id), 
    channel VARCHAR(50), 
    status VARCHAR(50), 
    error_message TEXT, 
    retry_count INT, 
    sent_at TIMESTAMP 
); 
 
CREATE TABLE in_app_notifications ( 
    id UUID PRIMARY KEY, 
    user_id VARCHAR(255) NOT NULL, 
    title VARCHAR(255), 
    body TEXT, 
    action_url VARCHAR(255), 
    is_read BOOLEAN DEFAULT FALSE, 
    created_at TIMESTAMP, 
    expires_at TIMESTAMP 
); 
				
			
notification_er_diagram
  • notification_logs: Represents the initial request fetched from Kafka.
  • notification_delivery: Represents the delivery attempt for each specific channel. If a request has Email and SMS, 2 Delivery records are created.
  • in_app_notifications: A dedicated table specifically designed for querying active UI bell notifications for users inside the portal or app.

Delivery Sequence

When dispatching, the service tracks every step to ensure no message is lost. Here is the sequence diagram showing how a Push notification gets grouped and sent:

Notification seq diagram

Building It Yourself: Dependencies & Providers

If you wanted to implement this service from scratch, you would need to set up a new Spring Boot project (using Spring Initializer) and include specific libraries to handle messaging and 3rd party APIs.

Here are the required pom.xml dependencies used in our architecture:

1. Spring Boot & Core Infrastructure

  • spring-boot-starter-web (REST APIs & Tomcat)
  • spring-boot-starter-data-jpa & postgresql (Hibernate database persistence)
  • flyway-core & flyway-database-postgresql (Database Migrations)
  • spring-boot-starter-kafka (Kafka Consumers)

2. Provider SDKs

  • Twilio (SMS)
				
					<dependency> 
    <groupId>com.twilio.sdk</groupId> 
    <artifactId>twilio</artifactId> 
    <version>10.1.0</version> 
</dependency>
				
			
  • Gmail (Email)
				
					<dependency> 
    <groupId>com.google.firebase</groupId> 
    <artifactId>firebase-admin</artifactId> 
    <version>9.2.0</version> 
</dependency> 
				
			
  • Firebase (Push)
				
					<dependency> 
    <groupId>org.springframework.boot</groupId> 
    <artifactId>spring-boot-starter-mail</artifactId> 
</dependency>
				
			

Writing a Provider Implementation

Once your dependencies are pulled down, creating a working provider requires creating a component that binds to the SDK. Below is exactly how we implemented the TwilioSmsProvider in Java using Twilio’s official SDK:

				
					@Service 
@Slf4j 
@ConditionalOnProperty(name = "channels.sms.provider", havingValue = "twilio", matchIfMissing = true) 
public class TwilioSmsProvider implements SmsProvider { 
 
    @Value("${twilio.account-sid}") 
    private String accountSid; 
 
    @Value("${twilio.auth-token}") 
    private String authToken; 
 
    @Value("${twilio.phone-number}") 
    private String fromPhoneNumber; 
 
    @PostConstruct 
    public void init() { 
        if (accountSid != null && authToken != null) { 
            Twilio.init(accountSid, authToken); 
            log.info("Twilio initialized with Account SID: {}", accountSid); 
        } else { 
            log.warn("Twilio credentials not found. SMS sending will fail."); 
        } 
    } 
 
    @Override 
    public boolean sendSms(String phoneNumber, String message) { 
        log.info("Sending SMS via Twilio to {}: {}", phoneNumber, message); 
        try { 
            Message.creator( 
                new PhoneNumber(phoneNumber), 
                new PhoneNumber(fromPhoneNumber), 
                message 
            ).create(); 
            return true; 
        } catch (Exception e) { 
            log.error("Failed to send SMS via Twilio: {}", e.getMessage()); 
            return false; 
        } 
    } 
} 
				
			

Setting Up Providers

To make the service fully functional, you need to configure the underlying providers. Here’s a quick guide on setting up Gmail, Twilio, and Firebase.

Gmail SMTP Setup (Email)

  • App Password: Go to your Google Account Security Settings, enable 2-Step Verification, and generate an App Password.
  • Configuration: Export the credentials as environment variables:
				
					export MAIL_USERNAME=your.email@gmail.com 
export MAIL_PASSWORD="your 16-char app password" 
				
			
Twilio Setup (SMS)
  • Credentials: Obtain your Account SID, Auth Token, and a Twilio phone number from the Twilio Console.
  • Configuration:
				
					export TWILIO_ACCOUNT_SID=your_account_sid 
export TWILIO_AUTH_TOKEN=your_auth_token 
export TWILIO_PHONE_NUMBER=+1234567890
				
			

Firebase Setup (Push Notifications)

  • Service Account: Go to the Firebase Console → Project Settings → Service accounts and generate a new private key.
  • Configuration: Export the absolute path to your downloaded JSON file:
				
					export FIREBASE_SERVICE_ACCOUNT_PATH=/path/to/my-project-firebase-adminsdk-xyz.json 
				
			

Extending the Service

The service uses the Strategy Pattern, making it incredibly easy to add new channels (e.g., Slack, WhatsApp) or replace existing providers.

Example: Adding a new Slack Channel

  1. Add Channel Enum: Update ChannelType to include SLACK.
  2. Create a Strategy: Implement the NotificationStrategy interface.
				
					@Component 
@RequiredArgsConstructor 
public class SlackStrategy implements NotificationStrategy { 
 
    private final SlackProvider slackProvider; 
 
    @Override 
    public CompletableFuture<SendResult> send(NotificationMessage message) { 
        // logic to extract data and call provider 
    } 
 
    @Override 
    public ChannelType getChannelType() { 
        return ChannelType.SLACK; 
    } 
 
    @Override 
    public ValidationResult validate(NotificationMessage message) { 
        // ensure webhook URL or channel ID is present 
    } 
}
				
			

Production Considerations

Provider Failures

Mail providers occasionally experience outages.

Notification systems should support provider replacement or failover strategies.

Rate Limiting

Providers often enforce sending limits.

Delivery infrastructure should account for these constraints.

Template Management

Notification templates should be versioned and managed independently of application deployments.

Audit Logging

Track:

  • Notification type
  • Recipient
  • Delivery status
  • Failure reason
  • Timestamp

This becomes critical for troubleshooting and compliance.

Monitoring

Monitor:

  • Delivery success rate
  • Retry volume
  • Consumer lag
  • Dead-letter volume
  • Provider latency

These metrics often reveal issues before users report them.

2. JJWT – Token Generation & Validation

JJWT (Java JWT) is the library used for building, signing, and parsing JWTs. All three artifacts are required: the API, the runtime implementation, and the Jackson serializer.

Common Mistakes

  • Sending Emails Directly from Business Services: Creates unnecessary coupling.
  • No Retry Mechanism: Temporary failures become permanent failures.
  • No Dead Letter Queue: Problematic events block processing.
  • Tight Coupling to Providers: Makes provider migration difficult.
  • No Delivery Tracking: Creates operational blind spots.

Conclusion

Notification systems are often treated as secondary infrastructure, yet they play a critical role in user experience and operational communication.

By adopting an event-driven architecture built on Kafka, the Notification Service decouples communication delivery from business workflows while improving reliability, scalability, and fault tolerance.

Business services remain focused on business capabilities. Notification infrastructure evolves independently. Failures become isolated rather than contagious.

As platforms grow and communication requirements expand, this separation becomes increasingly valuable, enabling new channels, providers, and delivery strategies without impacting core business services.

Source Code: https://github.com/inexture-solutions/springboot-microservices/tree/main/notification-service

FAQs

Why not send emails directly from User Service?

Doing so creates unnecessary coupling between user management and notification infrastructure.

Why Kafka instead of REST?

Kafka provides durability, retry capability, and independent scalability.

What happens if Notification Service goes down?
Events remain stored in Kafka until consumers become available again.

Why use Dead Letter Queues?

DLQs isolate permanently failing messages and prevent them from impacting normal processing.

Can this architecture support SMS and Push Notifications?
Yes. The architecture is channel-agnostic and can support additional delivery mechanisms.

Bharat Chaudhari is a Java Backend Engineer with 5+ years of experience building scalable microservices using Spring Boot. He specializes in distributed systems, event-driven architecture, Spring Security, Kafka, PostgreSQL, Redis, Docker, and Kubernetes, with a passion for building production-ready backend platforms.

Delivering Engineering Excellence Across Global Markets

  • India
  • USA
  • UAE
  • Europe
  • Singapore
  • Australia

Need Help Building Enterprise Spring Boot Microservices?

We specialize in designing secure, scalable, and event-driven microservices using Spring Boot, Kafka, PostgreSQL, Redis, OAuth2/JWT, Docker, Kubernetes, and cloud-native best practices for enterprise applications.