View source on GitHub

Module 48 - Messaging

What this module covers

Asynchronous messaging with Apache Kafka and RabbitMQ in a Spring Boot application. Kafka handles event streaming (ordered, persistent, replayable); RabbitMQ handles notifications with routing flexibility and dead-letter support.


Project structure

src/main/java/com/javatraining/messaging/
├── order/
│   └── OrderEvent.java                   # Kafka payload record
├── kafka/
│   ├── OrderEventProducer.java           # KafkaTemplate publisher
│   ├── OrderEventConsumer.java           # @KafkaListener
│   └── OrderProcessingService.java       # business logic invoked by consumer
└── rabbitmq/
    ├── RabbitConfig.java                 # exchange, queue, DLQ, converter beans
    ├── OrderNotification.java            # RabbitMQ payload record
    ├── OrderNotificationPublisher.java   # RabbitTemplate publisher
    ├── OrderNotificationListener.java    # @RabbitListener
    └── OrderNotificationHandler.java     # business logic invoked by listener

Kafka

Producing

KafkaTemplate<String, OrderEvent> sends JSON-serialized records to the topic.

public static final String TOPIC = "order-events";

public void publish(OrderEvent event) {
    kafkaTemplate.send(TOPIC, String.valueOf(event.orderId()), event);
}

application.properties wires the serializers:

spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer

Consuming

@KafkaListener binds a method to the topic; the consumer group is injected from properties to avoid hardcoding:

@KafkaListener(topics = OrderEventProducer.TOPIC, groupId = "${spring.kafka.consumer.group-id}")
public void consume(OrderEvent event) {
    processingService.process(event);
}

JsonDeserializer requires the trusted packages list so it can deserialize OrderEvent without throwing a type-mismatch:

spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer
spring.kafka.consumer.properties.spring.json.trusted.packages=com.javatraining.messaging.order

Integration testing with EmbeddedKafka

@EmbeddedKafka starts an in-process broker before the Spring context and overrides spring.kafka.bootstrap-servers automatically:

@SpringBootTest
@EmbeddedKafka(
        partitions = 1,
        topics = {OrderEventProducer.TOPIC},
        bootstrapServersProperty = "spring.kafka.bootstrap-servers"
)
class KafkaIntegrationTest {

    @Autowired OrderEventProducer orderEventProducer;
    @SpyBean OrderProcessingService orderProcessingService;
    @MockBean RabbitTemplate rabbitTemplate;   // satisfies RabbitMQ dependency

    @Test
    void consumer_processes_event_published_to_topic() {
        orderEventProducer.publish(new OrderEvent(1L, "CREATED", 10L, 2));

        verify(orderProcessingService, timeout(5000)).process(any(OrderEvent.class));
    }
}

@SpyBean wraps the real bean so verify can assert it was called without replacing its behaviour. timeout(5000) handles the asynchronous gap between send and the listener thread calling process.

@MockBean RabbitTemplate satisfies OrderNotificationPublisher’s dependency because RabbitAutoConfiguration is excluded in the test properties (see below).

Test classpath override

src/test/resources/application.properties completely replaces (not merges with) src/main/resources/application.properties on the test classpath. All Kafka properties required at startup must be repeated in the test file alongside the exclusion:

spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration
spring.kafka.consumer.group-id=messaging-module
spring.kafka.consumer.auto-offset-reset=earliest
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer
spring.kafka.consumer.properties.spring.json.trusted.packages=com.javatraining.messaging.order

RabbitMQ

Exchange, queue, and DLQ

public static final String ORDER_EXCHANGE   = "order.exchange";
public static final String ORDER_QUEUE      = "order.queue";
public static final String ORDER_DLQ        = "order.dlq";
public static final String ORDER_ROUTING_KEY = "order.created";

order.queue is declared with dead-letter arguments so RabbitMQ routes undeliverable messages to order.dlq automatically:

Queue orderQueue = QueueBuilder.durable(ORDER_QUEUE)
        .withArgument("x-dead-letter-exchange", "")
        .withArgument("x-dead-letter-routing-key", ORDER_DLQ)
        .build();

Message conversion

A Jackson2JsonMessageConverter bean converts POJOs to JSON on publish and back on consume - no manual serialization needed.

Producing

public void send(OrderNotification notification) {
    rabbitTemplate.convertAndSend(ORDER_EXCHANGE, ORDER_ROUTING_KEY, notification);
}

Consuming

@RabbitListener(queues = RabbitConfig.ORDER_QUEUE)
public void handleOrderNotification(OrderNotification notification) {
    handler.handle(notification);
}

If handler.handle() throws, Spring AMQP nacks the message. After retries are exhausted the dead-letter arguments route it to order.dlq.

Unit tests

Both publisher and listener are tested with plain Mockito - no Spring context:

@ExtendWith(MockitoExtension.class)
class OrderNotificationPublisherTest {

    @Mock RabbitTemplate rabbitTemplate;
    @InjectMocks OrderNotificationPublisher publisher;

    @Test
    void sends_to_correct_exchange_and_routing_key() {
        publisher.send(new OrderNotification(1L, "CREATED"));

        verify(rabbitTemplate).convertAndSend(
                RabbitConfig.ORDER_EXCHANGE,
                RabbitConfig.ORDER_ROUTING_KEY,
                new OrderNotification(1L, "CREATED")
        );
    }
}

Tests

Class Type Count
KafkaIntegrationTest @SpringBootTest 3
OrderNotificationPublisherTest Mockito unit 2
OrderNotificationListenerTest Mockito unit 2

Run: JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test Result: 7/7 pass


Key decisions

Decision Reason
@SpyBean instead of @MockBean for OrderProcessingService Spy wraps the real bean so verify works without replacing behaviour
timeout(5000) in Kafka assertions Consumer runs on a separate thread; Mockito must wait for the async call
RabbitAutoConfiguration excluded in test properties No RabbitMQ broker available during CI; @MockBean RabbitTemplate covers the dependency
Dead-letter via queue arguments Keeps routing policy in code alongside the queue declaration