How to Build a Scalable Notification Service with Spring Boot

The article explains why notification services are hard—slow delivery, unreliable third‑party providers, and status tracking—and shows how to solve these issues by fully decoupling the API from workers using a message queue, with detailed Spring Boot code examples and scaling strategies.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
How to Build a Scalable Notification Service with Spring Boot

Sending notifications may look trivial, but in practice developers hit three major obstacles: (1) each SMTP call or HTTP request to a push provider can take 100 ms to several seconds, blocking the main API thread; (2) third‑party services are flaky or rate‑limited, requiring retry mechanisms; (3) tracking whether a user opened an email or a push notification becomes a tangled mess without early architectural consideration.

The core remedy is to make the entire notification flow asynchronous, completely decoupling request handling from the actual sending logic.

Client request → REST API → Message Queue → Workers → Email/Push/SMS

The API layer only validates input, builds a Notification object, pushes it onto the queue, and immediately returns a 202 response, keeping the request path fast.

A message queue (RabbitMQ or Kafka) acts as a buffer: slow third‑party responses only pile up in the queue, preventing API time‑outs, and queued messages survive worker crashes. It also enables independent horizontal scaling of workers.

Queue selection is discussed: RabbitMQ offers strong routing and reliability with a lightweight architecture, while Kafka provides higher throughput at the cost of operational complexity.

public class Notification {
  private String id;
  private String toUser; // email, push, sms
  private String channel;
  private String subject;
  private String body;
  private Map<String, String> metadata;
}

The controller validates the request and publishes the notification:

@RestController
@RequestMapping("/api/notifications")
public class NotificationController {
  private final NotificationPublisher publisher;
  public NotificationController(NotificationPublisher publisher) { this.publisher = publisher; }
  @PostMapping
  public ResponseEntity<Void> sendNotification(@RequestBody NotificationRequest request) {
    Notification notification = buildNotification(request);
    publisher.publish(notification);
    return ResponseEntity.accepted().build();
  }
}

The publisher sends the message to a RabbitMQ topic exchange, using a routing key derived from the channel:

@Component
public class NotificationPublisher {
  private final RabbitTemplate rabbitTemplate;
  public NotificationPublisher(RabbitTemplate rabbitTemplate) { this.rabbitTemplate = rabbitTemplate; }
  public void publish(Notification notification) {
    rabbitTemplate.convertAndSend(
      "notification.exchange",
      "notification.routing.%s".formatted(notification.getChannel().toLowerCase()),
      notification);
  }
}

Workers consume messages per channel and delegate to concrete senders:

@Component
public class NotificationWorker {
  private final EmailSender emailSender;
  private final PushSender pushSender;
  private final SmsSender smsSender;

  @RabbitListener(queues = "notification.email.queue")
  public void handleEmail(Notification notification) { emailSender.send(notification); }

  @RabbitListener(queues = "notification.push.queue")
  public void handlePush(Notification notification) { pushSender.send(notification); }

  @RabbitListener(queues = "notification.sms.queue")
  public void handleSms(Notification notification) { smsSender.send(notification); }
}

Email sending uses Spring’s JavaMailSender:

public class EmailSender implements NotificationSender {
  private final JavaMailSender mailSender;
  @Override
  public void send(Notification notification) {
    SimpleMailMessage message = new SimpleMailMessage();
    message.setTo(notification.getToUser());
    message.setSubject(notification.getSubject());
    message.setText(notification.getBody());
    mailSender.send(message);
  }
}

Fault tolerance is addressed with a dead‑letter queue (DLQ). When a consumer repeatedly fails, RabbitMQ moves the message to a DLQ after the retry limit, allowing manual inspection or discarding.

@Bean
public Queue xxxQueue() { // xxx = email, push, sms
  return QueueBuilder.durable("notification.xxx.queue")
    .withArgument("x-dead-letter-exchange", "notification.dlx.exchange")
    .withArgument("x-dead-letter-routing-key", "notification.dlx.routing")
    .build();
}

Idempotency is ensured by storing a unique message ID in Redis; the first worker that sees the ID succeeds, others skip the duplicate:

@Component
public class IdempotencyChecker {
  private final StringRedisTemplate redisTemplate;
  public boolean isDuplicate(String messageId) {
    Boolean wasSet = redisTemplate.opsForValue()
      .setIfAbsent("notification:%s".formatted(messageId), "processed", Duration.ofHours(24));
    return Boolean.FALSE.equals(wasSet);
  }
}

Rate limiting for email is wrapped with Guava’s RateLimiter (50 emails per second):

@Component
public class RateLimitedEmailSender implements NotificationSender {
  private final RateLimiter rateLimiter = RateLimiter.create(50); // 50 emails/sec
  private final EmailSender delegate;
  public RateLimitedEmailSender(EmailSender delegate) { this.delegate = delegate; }
  @Override
  public void send(Notification notification) {
    rateLimiter.acquire();
    delegate.send(notification);
  }
}

Scaling considerations:

API layer is stateless and can be horizontally scaled behind a load balancer; the queue prevents API time‑outs during peak loads.

Worker nodes are scaled up or down based on queue depth, using RabbitMQ management API or Spring Boot Actuator metrics; each channel has its own queue to allow independent scaling.

Batch sending can be employed for high‑volume email or push notifications when the provider supports bulk operations.

For dynamic RabbitMQ scaling, see the linked article on adjusting concurrency at runtime.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

javascalabilityasynchronousspring-bootrabbitmqidempotencyrate-limitingnotification-service
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.