10. Design a Notification System in Java : FAANG Interviews

Designing a scalable notification system is a typical problem encountered during FAANG interviews. This system aims to send notifications to users in real-time, support different notification types such as email, SMS, and in-app, and ensure delivery reliability. This article will discuss key design concepts, tools, and practices for building such a notification system using Java, including handling message queues, user preferences, delivery acknowledgment, and retries.


Key Components of the Notification System

  1. Message Queues: A message queue such as Apache Kafka or RabbitMQ helps manage the flow of notifications to different services, ensuring that messages are processed asynchronously and efficiently. These tools allow the notification system to scale horizontally by decoupling the message producers from the consumers.

  2. User Preferences: A notification system needs to respect user preferences regarding the types of notifications they want to receive and the delivery channels (email, SMS, in-app, etc.). This requires storing user preferences and filtering the notifications accordingly before sending them.

  3. Delivery Acknowledgment and Retries: Notifications need to be reliably delivered. In case of failures, such as network errors or service downtime, the system should be able to acknowledge the failure and retry sending the notification.

  4. Scalability: The system should be designed to scale as the user base grows. This can be achieved by distributing the load across multiple servers, leveraging message queues, and using stateless services to handle increased traffic.


Architecture Overview

Let’s break down the architecture of the system and the components involved:

  1. Producer: The producer is responsible for generating notifications, which may come from various sources, such as events, user actions, or system alerts. These notifications are added to a message queue (e.g., Kafka or RabbitMQ) for further processing.

  2. Consumer: Consumers process notifications from the queue. Each consumer can handle a specific type of notification (email, SMS, in-app) and can be deployed as a distributed service.

  3. User Preferences Service: This service stores user preferences (e.g., which notifications they want to receive and via which channels). When a notification is ready to be sent, the system checks the user's preferences to determine whether it should be delivered and through which channel.

  4. Notification Dispatcher: This service sends notifications to the appropriate channels, whether via email, SMS, or in-app. It interacts with external services like email providers or SMS gateways.

  5. Acknowledgment and Retry Mechanism: Each notification sent will need to be acknowledged by the respective external service (e.g., an email service). If the notification fails to be delivered, the system will retry sending it based on a predefined policy.


Example Implementation in Java

Dependencies

For our implementation, we will use Apache Kafka for the message queue and Java Mail API to send emails. Let’s first include the necessary dependencies:

<dependencies>
    <!-- Apache Kafka dependency -->
    <dependency>
        <groupId>org.apache.kafka</groupId>
        <artifactId>kafka-clients</artifactId>
        <version>3.0.0</version>
    </dependency>
    
    <!-- JavaMail for email notifications -->
    <dependency>
        <groupId>javax.mail</groupId>
        <artifactId>javax.mail-api</artifactId>
        <version>1.6.2</version>
    </dependency>
    
    <!-- Spring Boot for general structure -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
</dependencies>

Kafka Producer (Sending Notifications)

First, let’s create a Kafka producer to send notifications to the message queue.

import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;

import java.util.Properties;

public class NotificationProducer {
    
    private final KafkaProducer<String, String> producer;
    
    public NotificationProducer() {
        Properties properties = new Properties();
        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        
        this.producer = new KafkaProducer<>(properties);
    }
    
    public void sendNotification(String message) {
        ProducerRecord<String, String> record = new ProducerRecord<>("notification_topic", message);
        
        producer.send(record, (metadata, exception) -> {
            if (exception != null) {
                System.out.println("Error sending message: " + exception.getMessage());
            } else {
                System.out.println("Message sent to topic: " + metadata.topic());
            }
        });
    }

    public static void main(String[] args) {
        NotificationProducer producer = new NotificationProducer();
        producer.sendNotification("New Event Notification for User");
    }
}

Kafka Consumer (Processing Notifications)

Next, let’s create a Kafka consumer that processes the messages from the queue. Based on the notification type, it will route the message to the correct delivery method (email, SMS, or in-app).

import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.serialization.StringDeserializer;

import java.util.Properties;

public class NotificationConsumer {

    private final KafkaConsumer<String, String> consumer;

    public NotificationConsumer() {
        Properties properties = new Properties();
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, "notification_group");
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());

        this.consumer = new KafkaConsumer<>(properties);
    }

    public void consumeMessages() {
        consumer.subscribe(List.of("notification_topic"));

        while (true) {
            ConsumerRecords<String, String> records = consumer.poll(100);
            for (ConsumerRecord<String, String> record : records) {
                String notificationMessage = record.value();
                // Here, we could add logic to process the message based on user preferences
                System.out.println("Received message: " + notificationMessage);
                sendNotification(notificationMessage);
            }
        }
    }

    public void sendNotification(String notificationMessage) {
        // Add logic for sending the notification (email, SMS, or in-app)
        System.out.println("Sending notification: " + notificationMessage);
    }

    public static void main(String[] args) {
        NotificationConsumer consumer = new NotificationConsumer();
        consumer.consumeMessages();
    }
}

Handling Delivery Acknowledgments and Retries

In a real-world scenario, after attempting to send a notification (email, SMS, etc.), the system should acknowledge the delivery (e.g., by receiving a response from the email provider). If the delivery fails, the system will retry according to a predefined policy (e.g., exponential backoff).

Here's a basic retry logic in Java:

public class NotificationRetryService {

    private static final int MAX_RETRIES = 5;
    
    public boolean sendWithRetry(Notification notification) {
        int retries = 0;
        while (retries < MAX_RETRIES) {
            try {
                // Send the notification (email, SMS, etc.)
                sendNotification(notification);
                return true; // If successful
            } catch (Exception e) {
                retries++;
                if (retries >= MAX_RETRIES) {
                    System.out.println("Failed to deliver notification after " + retries + " attempts");
                    return false; // Delivery failed after retries
                }
                try {
                    Thread.sleep((long) Math.pow(2, retries) * 1000); // Exponential backoff
                } catch (InterruptedException ex) {
                    Thread.currentThread().interrupt();
                }
            }
        }
        return false;
    }

    public void sendNotification(Notification notification) {
        // Actual implementation for sending a notification
        System.out.println("Sending notification: " + notification.getMessage());
    }
}

Summary

In this article, we have designed a scalable and efficient notification system using Java and Apache Kafka. We covered the following concepts:

  1. Message Queues (Kafka) for decoupling the message producers from consumers.
  2. User preferences are used to ensure notifications are sent according to user preferences.
  3. Delivery Acknowledgment and Retries to ensure reliable delivery of notifications.
  4. Scalability through horizontal scaling of consumers and message queues.

This system can be expanded to handle more complex notification types, integrate additional channels, and implement advanced features like prioritization and filtering based on user preferences.

Please stay tuned. I will update Point 5 of the FANNG Interview series. The top 10 interview questions are listed here.

0 comments:

Post a Comment