00:00

Spring Boot Integration with RabbitMQ

Last updated:

Spring Boot integration with RabbitMQ lets your services talk by sending messages through a broker instead of calling each other directly. The sender drops a message off and moves on. RabbitMQ holds it safely and hands it to the right consumers, even if they are busy or briefly offline. This keeps services decoupled and makes slow work, such as sending emails or reserving stock, happen in the background.

This tutorial builds a shop service that publishes order events and handles them in two consumers. You will declare exchanges, queues and bindings, send JSON messages with RabbitTemplate, consume them with @RabbitListener, confirm every publish, retry failures and park poison messages in a dead letter queue, and test the flow against a real broker.

1. What Is RabbitMQ and Spring AMQP?

RabbitMQ is an open-source message broker: a server that receives messages from producers, stores them in queues and delivers them to consumers. It speaks AMQP (Advanced Message Queuing Protocol), an open standard for messaging.

Spring AMQP is the Spring project that makes RabbitMQ feel like the rest of Spring. It gives you RabbitTemplate for sending, @RabbitListener for receiving, and builders for declaring the broker setup in Java. Spring Boot auto-configures all of it from spring.rabbitmq.* properties.

A simple way to picture it: RabbitMQ is a post office. Producers hand letters to a sorting desk (the exchange), the desk puts each letter into the right mailboxes (queues), and each recipient empties their own mailbox at their own pace.

2. Why Use RabbitMQ in a Spring Boot Application

When a customer places an order, several things must happen: reserve stock, send a confirmation email, update analytics. Doing all of it inside the HTTP request makes the request slow, and a failure in any one step fails the whole order.

With RabbitMQ, the order service publishes one message and returns. Each follow-up task runs in its own consumer:

  • Faster responses. The customer doesn't wait for emails or stock updates.
  • Resilience. If the email service is down, its messages wait in the queue until it comes back.
  • Load levelling. A burst of orders fills the queue, and consumers work through it at a steady rate.
  • Loose coupling. The order service doesn't know who consumes its events.

If all your listeners live in one application, Spring's in-process application events may be enough. A broker becomes worthwhile when messages must cross service boundaries or survive restarts.

3. How RabbitMQ Routes Messages: Exchanges, Queues and Bindings

The key idea in RabbitMQ is that producers never send to a queue directly. They send to an exchange, together with a short text label called a routing key. A binding is a rule that links a queue to an exchange, and the exchange uses the bindings to decide which queues get a copy.

How RabbitMQ routes an order event in Spring BootOrderEventPublisher sends an order event with routing key order.placed to the topic exchange shop.orders. The exchange copies it to every queue whose binding matches: inventory.order-placed, bound with order.placed, and notification.orders, bound with order.#. InventoryListener and NotificationListener each consume from their own queue. Producer OrderEventPublisher key: order.placed Exchange shop.orders type: topic binding order.placed binding order.# Queue inventory.order-placed Queue notification.orders Consumer InventoryListener Consumer NotificationListener
Figure 1: the exchange compares the routing key with each binding and puts a copy of the message in every matching queue.
Exchange typeHow it routesTypical use
directRouting key must equal the binding key exactlyCommands to one specific queue
topicPattern match on dot-separated words: * matches one word, # matches zero or moreEvents that different consumers filter differently
fanoutIgnores the routing key and copies to every bound queueBroadcasts, such as cache-clear signals
headersMatches message headers instead of the routing keyRare; routing on several attributes

In the example, the inventory queue is bound with order.placed, so it only gets new orders. The notification queue is bound with order.#, so it would also receive events like order.cancelled once you add them. Once a message is in a queue, it is delivered to one consumer of that queue and removed when the consumer acknowledges it.

4. Setup: Dependencies and a Local RabbitMQ Broker

4.1 Dependencies

pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>

    <!-- Test -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-rabbitmq</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.awaitility</groupId>
        <artifactId>awaitility</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

spring-boot-starter-amqp brings Spring AMQP and the RabbitMQ Java client. The web starter provides the REST endpoint and Jackson, which the JSON converter uses. The test dependencies are used in Section 10.

Version note:
  • JSON converter: Spring AMQP 4 (used by Spring Boot 4) is built for Jackson 3. Use JacksonJsonMessageConverter. The Jackson2JsonMessageConverter from most Spring Boot 3.x tutorials is deprecated.
  • Retry setting renamed: listener retries now use Spring Framework's own retry support instead of Spring Retry. The property spring.rabbitmq.listener.simple.retry.max-attempts (total attempts) was replaced by max-retries (retries after the first attempt).
  • Test starter: Spring Boot 4 adds spring-boot-starter-amqp-test, which also brings spring-boot-starter-test.
  • Testcontainers 2: the RabbitMQ container class is org.testcontainers.rabbitmq.RabbitMQContainer, in the testcontainers-rabbitmq module.

4.2 A Local RabbitMQ Broker

compose.yaml
services:
  rabbitmq:
    image: rabbitmq:4.2-management
    environment:
      RABBITMQ_DEFAULT_USER: shop
      RABBITMQ_DEFAULT_PASS: shop-secret
    ports:
      - "5672:5672"
      - "15672:15672"
Terminal
docker compose up -d

The -management image includes a web console at http://localhost:15672, where you can watch exchanges, queues and message counts. Log in with the user from the Compose file.

4.3 Application Properties

application.properties
spring.application.name=shop-service
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=shop
spring.rabbitmq.password=shop-secret

# Wait for the broker to confirm each published message
spring.rabbitmq.publisher-confirm-type=correlated

# Listener retries: first attempt + 2 retries, 1 second apart, then reject (dead-letter)
spring.rabbitmq.listener.simple.retry.enabled=true
spring.rabbitmq.listener.simple.retry.max-retries=2
spring.rabbitmq.listener.simple.retry.initial-interval=1s

Each group of settings is explained where it is used: publisher confirms in Section 6.3, and listener retries in Section 7.

5. Core Building Blocks of Spring AMQP

5.1 RabbitTemplate: Sending Messages

Spring Boot auto-configures a RabbitTemplate. convertAndSend(exchange, routingKey, object) converts the object to a message body with the configured converter and publishes it. The optional CorrelationData argument lets you receive the broker's confirmation (Section 6.3).

OrderEventPublisher.java (excerpt)
rabbitTemplate.convertAndSend("shop.orders", "order.placed", event, correlation);

5.2 @RabbitListener: Receiving Messages

@RabbitListener turns a method into a consumer. Spring runs a listener container that keeps a connection open, receives messages, converts the body to the method's parameter type and calls your method.

InventoryListener.java (excerpt)
@RabbitListener(queues = "inventory.order-placed")
public void onOrderPlaced(OrderPlacedEvent event) {
    stockService.reserve(event.orderId(), event.sku(), event.quantity());
}
AttributeWhat it does
queuesNames of existing queues to consume from
concurrencyNumber of consumer threads, for example "2" or "2-5"
idListener ID, useful for starting or stopping it at runtime
ackModeOverrides the container's acknowledge mode for this listener
containerFactoryBean name of a custom listener container factory
bindingsDeclares a queue, exchange and binding inline with @QueueBinding

5.3 Declaring Exchanges, Queues and Bindings

Declare broker objects as beans of type Exchange, Queue and Binding. The auto-configured AmqpAdmin creates them on the broker when the first connection opens. If they already exist with the same settings, nothing changes.

RabbitConfig.java (excerpt)
@Bean
public Queue inventoryQueue() {
    return QueueBuilder.durable("inventory.order-placed")
            .deadLetterExchange("shop.orders.dlx")
            .deadLetterRoutingKey("inventory.order-placed.dlq")
            .build();
}

A durable queue survives a broker restart. The two deadLetter… arguments tell the broker where to send messages that a consumer rejects (Section 7).

5.4 JSON Messages with JacksonJsonMessageConverter

By default, Spring AMQP only handles strings, byte arrays and Java-serialized objects. Declare a JacksonJsonMessageConverter bean, and Spring Boot uses it for both the template and the listeners. Messages are sent as JSON with an application/json content type and a type header. For @RabbitListener methods, the converter uses the method's parameter type by default. The trusted-package list limits which classes a type header is allowed to create.

5.5 Acknowledgements and Prefetch

An acknowledgement (ack) tells the broker a message was handled, so it can be deleted. In the default AUTO mode, Spring acks when your method returns and rejects the message when it throws. Prefetch is how many unacknowledged messages the broker pushes to each consumer in advance. The default is 250, which is good for throughput. Lower it for slow, heavy messages, so one consumer doesn't hoard work that others could do.

6. Spring Boot RabbitMQ Producer and Consumer Example

The example keeps the producer and both consumers in one application, so you can run it with one command. In a real system, each listener would live in its own service. The REST layer follows the Spring Boot REST API tutorial.

6.1 Application and Broker Configuration

ShopApplication.java
package com.javabytechie.shop;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ShopApplication {

    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}
RabbitConfig.java
package com.javabytechie.shop.messaging;

import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.ExchangeBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.QueueBuilder;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitConfig {

    public static final String ORDERS_EXCHANGE = "shop.orders";
    public static final String ORDER_PLACED_KEY = "order.placed";

    public static final String INVENTORY_QUEUE = "inventory.order-placed";
    public static final String NOTIFICATION_QUEUE = "notification.orders";

    public static final String DEAD_LETTER_EXCHANGE = "shop.orders.dlx";
    public static final String INVENTORY_DLQ = "inventory.order-placed.dlq";

    @Bean
    public TopicExchange ordersExchange() {
        return ExchangeBuilder.topicExchange(ORDERS_EXCHANGE).build();
    }

    @Bean
    public Queue inventoryQueue() {
        return QueueBuilder.durable(INVENTORY_QUEUE)
                .deadLetterExchange(DEAD_LETTER_EXCHANGE)
                .deadLetterRoutingKey(INVENTORY_DLQ)
                .build();
    }

    @Bean
    public Queue notificationQueue() {
        return QueueBuilder.durable(NOTIFICATION_QUEUE).build();
    }

    @Bean
    public Binding inventoryBinding(Queue inventoryQueue, TopicExchange ordersExchange) {
        return BindingBuilder.bind(inventoryQueue).to(ordersExchange).with(ORDER_PLACED_KEY);
    }

    @Bean
    public Binding notificationBinding(Queue notificationQueue, TopicExchange ordersExchange) {
        // "order.#" matches every order event: order.placed, order.cancelled, ...
        return BindingBuilder.bind(notificationQueue).to(ordersExchange).with("order.#");
    }

    @Bean
    public DirectExchange deadLetterExchange() {
        return ExchangeBuilder.directExchange(DEAD_LETTER_EXCHANGE).build();
    }

    @Bean
    public Queue inventoryDeadLetterQueue() {
        return QueueBuilder.durable(INVENTORY_DLQ).build();
    }

    @Bean
    public Binding inventoryDeadLetterBinding(Queue inventoryDeadLetterQueue, DirectExchange deadLetterExchange) {
        return BindingBuilder.bind(inventoryDeadLetterQueue).to(deadLetterExchange).with(INVENTORY_DLQ);
    }

    @Bean
    public MessageConverter jsonMessageConverter() {
        // JSON bodies; only classes from our own event package may be created from type headers
        return new JacksonJsonMessageConverter("com.javabytechie.shop.order");
    }
}

6.2 Request, Event and Response Records

PlaceOrderRequest.java
package com.javabytechie.shop.order;

import java.math.BigDecimal;

public record PlaceOrderRequest(String customerId, String sku, int quantity, BigDecimal totalAmount) {
}
OrderPlacedEvent.java
package com.javabytechie.shop.order;

import java.math.BigDecimal;
import java.time.Instant;

public record OrderPlacedEvent(
        String orderId,
        String customerId,
        String sku,
        int quantity,
        BigDecimal totalAmount,
        Instant placedAt) {
}
OrderAcceptedResponse.java
package com.javabytechie.shop.order;

public record OrderAcceptedResponse(String orderId, String status) {
}

6.3 The Producer with Publisher Confirms

With spring.rabbitmq.publisher-confirm-type=correlated, the broker sends a publisher confirm once it has taken responsibility for the message. The publisher waits up to 5 seconds for it. If the confirm is negative or doesn't arrive, it throws OrderPublishException, which the controller turns into 503 Service Unavailable. So 202 Accepted always means the broker has the message.

OrderPublishException.java
package com.javabytechie.shop.order;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public class OrderPublishException extends RuntimeException {

    public OrderPublishException(String orderId, String reason) {
        super("Could not publish order " + orderId + ": " + reason);
    }
}
OrderEventPublisher.java
package com.javabytechie.shop.order;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;

import com.javabytechie.shop.messaging.RabbitConfig;

@Service
public class OrderEventPublisher {

    private static final Logger log = LoggerFactory.getLogger(OrderEventPublisher.class);

    private final RabbitTemplate rabbitTemplate;

    public OrderEventPublisher(RabbitTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }

    public void publish(OrderPlacedEvent event) {
        CorrelationData correlation = new CorrelationData(event.orderId());
        rabbitTemplate.convertAndSend(RabbitConfig.ORDERS_EXCHANGE, RabbitConfig.ORDER_PLACED_KEY, event, correlation);

        CorrelationData.Confirm confirm;
        try {
            confirm = correlation.getFuture().get(5, TimeUnit.SECONDS);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
            throw new OrderPublishException(event.orderId(), "interrupted while waiting for the broker");
        } catch (ExecutionException | TimeoutException ex) {
            throw new OrderPublishException(event.orderId(), "no confirm from the broker within 5 seconds");
        }

        if (!confirm.ack()) {
            throw new OrderPublishException(event.orderId(), "broker rejected the message: " + confirm.reason());
        }
        log.info("Published order {} with routing key {}", event.orderId(), RabbitConfig.ORDER_PLACED_KEY);
    }
}
OrderController.java
package com.javabytechie.shop.order;

import java.time.Instant;
import java.util.UUID;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderEventPublisher publisher;

    public OrderController(OrderEventPublisher publisher) {
        this.publisher = publisher;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.ACCEPTED)
    public OrderAcceptedResponse placeOrder(@RequestBody PlaceOrderRequest request) {
        String orderId = UUID.randomUUID().toString();
        OrderPlacedEvent event = new OrderPlacedEvent(orderId, request.customerId(), request.sku(),
                request.quantity(), request.totalAmount(), Instant.now());

        publisher.publish(event);
        return new OrderAcceptedResponse(orderId, "PENDING");
    }
}

A positive confirm means the exchange accepted the message. If no queue matches the routing key, the broker still confirms and then drops the message. Declaring the bindings in code, as above, prevents that mismatch.

6.4 The Consumers

StockService.java
package com.javabytechie.shop.inventory;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

@Service
public class StockService {

    private static final Logger log = LoggerFactory.getLogger(StockService.class);

    // In-memory for the example; a real service would use a database table
    private final Map<String, Integer> stock = new HashMap<>(Map.of("KB-100", 50, "MS-200", 20));
    private final Set<String> processedOrders = new HashSet<>();

    public synchronized void reserve(String orderId, String sku, int quantity) {
        if (processedOrders.contains(orderId)) {
            log.info("Order {} was already processed, skipping duplicate", orderId);
            return;
        }
        int available = stock.getOrDefault(sku, 0);
        if (available < quantity) {
            throw new InsufficientStockException(sku, quantity, available);
        }
        stock.put(sku, available - quantity);
        processedOrders.add(orderId);
        log.info("Reserved {} x {} for order {} ({} left)", quantity, sku, orderId, available - quantity);
    }

    public synchronized int availableStock(String sku) {
        return stock.getOrDefault(sku, 0);
    }
}
InsufficientStockException.java
package com.javabytechie.shop.inventory;

public class InsufficientStockException extends RuntimeException {

    public InsufficientStockException(String sku, int requested, int available) {
        super("Not enough stock for " + sku + ": requested " + requested + ", available " + available);
    }
}
InventoryListener.java
package com.javabytechie.shop.inventory;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

import com.javabytechie.shop.messaging.RabbitConfig;
import com.javabytechie.shop.order.OrderPlacedEvent;

@Component
public class InventoryListener {

    private static final Logger log = LoggerFactory.getLogger(InventoryListener.class);

    private final StockService stockService;

    public InventoryListener(StockService stockService) {
        this.stockService = stockService;
    }

    @RabbitListener(queues = RabbitConfig.INVENTORY_QUEUE)
    public void onOrderPlaced(OrderPlacedEvent event) {
        log.info("Reserving stock for order {}", event.orderId());
        stockService.reserve(event.orderId(), event.sku(), event.quantity());
    }
}
NotificationListener.java
package com.javabytechie.shop.notification;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.support.AmqpHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Component;

import com.javabytechie.shop.messaging.RabbitConfig;
import com.javabytechie.shop.order.OrderPlacedEvent;

@Component
public class NotificationListener {

    private static final Logger log = LoggerFactory.getLogger(NotificationListener.class);

    @RabbitListener(queues = RabbitConfig.NOTIFICATION_QUEUE)
    public void onOrderEvent(OrderPlacedEvent event,
                             @Header(AmqpHeaders.RECEIVED_ROUTING_KEY) String routingKey) {
        log.info("Emailing customer {} about {} for order {}", event.customerId(), routingKey, event.orderId());
    }
}

RabbitMQ delivers at least once. If a consumer crashes after doing the work but before the ack reaches the broker, the message is delivered again. StockService remembers processed order IDs, so a redelivery doesn't reserve stock twice. This property is called idempotency.

6.5 The Dead Letter Listener

DeadLetterListener.java
package com.javabytechie.shop.messaging;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Component;

import com.javabytechie.shop.order.OrderPlacedEvent;

@Component
public class DeadLetterListener {

    private static final Logger log = LoggerFactory.getLogger(DeadLetterListener.class);

    @RabbitListener(queues = RabbitConfig.INVENTORY_DLQ)
    public void onDeadLetter(OrderPlacedEvent event,
                             @Header(name = "x-first-death-queue", required = false) String sourceQueue) {
        log.warn("Order {} needs manual review (dead-lettered from {})", event.orderId(), sourceQueue);
    }
}

When RabbitMQ dead-letters a message, it adds headers such as x-first-death-queue that record where the message came from and why it was dead-lettered.

6.6 Run It

Terminal
docker compose up -d
./mvnw spring-boot:run

curl -i -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerId":"C-1001","sku":"KB-100","quantity":2,"totalAmount":178.00}'
Console
HTTP/1.1 202
{"orderId":"3e9a51c2-7d4b-4f1e-a6c8-2b5d9e0f1a37","status":"PENDING"}
Console
INFO c.j.shop.order.OrderEventPublisher       : Published order 3e9a51c2-7d4b-4f1e-a6c8-2b5d9e0f1a37 with routing key order.placed
INFO c.j.shop.inventory.InventoryListener     : Reserving stock for order 3e9a51c2-7d4b-4f1e-a6c8-2b5d9e0f1a37
INFO c.j.shop.inventory.StockService          : Reserved 2 x KB-100 for order 3e9a51c2-7d4b-4f1e-a6c8-2b5d9e0f1a37 (48 left)
INFO c.j.s.notification.NotificationListener  : Emailing customer C-1001 about order.placed for order 3e9a51c2-7d4b-4f1e-a6c8-2b5d9e0f1a37

Both queues received a copy of the same event. The lines from the two listeners can appear in either order, because they run on different threads. Now order more MS-200 mice than are in stock:

Terminal
curl -i -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerId":"C-1002","sku":"MS-200","quantity":500,"totalAmount":12500.00}'
Console
INFO c.j.shop.inventory.InventoryListener     : Reserving stock for order 7b1f0d3a-5c2e-4a9b-8e6f-4d2c1b0a9f88
INFO c.j.shop.inventory.InventoryListener     : Reserving stock for order 7b1f0d3a-5c2e-4a9b-8e6f-4d2c1b0a9f88
INFO c.j.shop.inventory.InventoryListener     : Reserving stock for order 7b1f0d3a-5c2e-4a9b-8e6f-4d2c1b0a9f88
WARN c.j.shop.messaging.DeadLetterListener    : Order 7b1f0d3a-5c2e-4a9b-8e6f-4d2c1b0a9f88 needs manual review (dead-lettered from inventory.order-placed)

The listener ran three times, one second apart, then the message landed in the dead letter queue. Order IDs will differ on your machine, and Spring logs a few extra framework lines that are omitted here.

7. Error Handling: Retries and Dead Letter Queues

Retry and dead-letter flow for a failing RabbitMQ listenerA message from inventory.order-placed reaches InventoryListener. If the method returns normally, the message is acknowledged and removed. If it throws, the retry interceptor calls it again up to two more times, one second apart. If it still fails, RejectAndDontRequeueRecoverer rejects the message without requeueing, and the broker sends it through the dead letter exchange shop.orders.dlx to the queue inventory.order-placed.dlq. Queue inventory.order-placed InventoryListener onOrderPlaced() returns normally ack: message removed throws Retry interceptor 2 retries, 1 s apart retry still fails Reject, do not requeue RejectAndDontRequeueRecoverer broker dead-letters it inventory.order-placed.dlq via exchange shop.orders.dlx
Figure 2: three attempts in total, then the broker moves the message to the dead letter queue instead of dropping it.

Without any configuration, a listener that throws an exception has its message rejected and requeued. The same message is then delivered again immediately, fails again, and loops forever. This is the most common RabbitMQ problem in Spring apps. The example avoids it in two steps:

  1. Retries in the consumer. spring.rabbitmq.listener.simple.retry.enabled=true wraps the listener in a retry interceptor. With max-retries=2 and initial-interval=1s, a failing message gets three attempts, one second apart, inside the same delivery.
  2. Reject without requeue, then dead-letter. When retries run out, Spring Boot's default recoverer, RejectAndDontRequeueRecoverer, rejects the message without requeueing it. Because the queue was declared with x-dead-letter-exchange, the broker moves it to shop.orders.dlx, which routes it to inventory.order-placed.dlq. Without a dead letter exchange, a rejected message is simply deleted.

Messages whose body can't be converted to OrderPlacedEvent also end up there: Spring treats a conversion failure as fatal and rejects the message without requeueing it. Note that the retry settings apply to every exception. Retrying InsufficientStockException can't succeed, so in production you would exclude such errors with a RabbitListenerRetrySettingsCustomizer bean, or keep them as normal results instead of exceptions.

Retries also hold the message, and the consumer thread, while they wait. Keep them short. For longer delays, route failures to a "wait" queue with a message TTL that dead-letters back to the main queue.

8. Spring Boot RabbitMQ Configuration Properties

PropertyDefaultWhat it controls
spring.rabbitmq.host / portlocalhost / 5672Broker address
spring.rabbitmq.username / passwordguest / guestLogin; guest works only from localhost
spring.rabbitmq.virtual-hostbroker default (/)Isolated namespace on a shared broker
spring.rabbitmq.publisher-confirm-typenonecorrelated enables per-message confirms
spring.rabbitmq.listener.simple.acknowledge-modeautoauto, manual or none
spring.rabbitmq.listener.simple.prefetch250Unacknowledged messages per consumer
spring.rabbitmq.listener.simple.concurrency1Consumer threads per listener
spring.rabbitmq.listener.simple.default-requeue-rejectedtrueWhether a failed message goes back to the queue
spring.rabbitmq.listener.simple.retry.enabledfalseRetry failing listener calls
spring.rabbitmq.listener.simple.retry.max-retries3Retries after the first attempt
spring.rabbitmq.listener.simple.retry.initial-interval1000msWait before the first retry; multiplier and max-interval add back-off

The defaults for prefetch, concurrency, acknowledge mode and requeue come from Spring AMQP's listener container. Spring Boot only sets these when you configure the property.

9. Common RabbitMQ Errors and How to Fix Them

Error or symptomCauseFix
The same message is processed in an endless loopThe listener throws and the message is requeued every timeEnable listener retries and a dead letter exchange, or set default-requeue-rejected=false
ACCESS_REFUSED - Login was refusedThe guest user can only connect from localhost, or the credentials are wrongCreate a dedicated user, as the Compose file does
PRECONDITION_FAILED - inequivalent arg 'x-dead-letter-exchange'The queue already exists with different argumentsDelete the queue and let the app recreate it, or manage arguments with broker policies
Messages disappear after retriesRejected messages are deleted when the queue has no dead letter exchangeDeclare the queue with deadLetterExchange(...)
Listener gets byte[] or fails to convertNo JSON converter bean, or the sender used a different content typeDeclare JacksonJsonMessageConverter on both sides
Messages lost after a broker restartThe queue was non-durableUse QueueBuilder.durable(...)
Stock reserved twiceAt-least-once redelivery after a crashMake listeners idempotent

10. Testing RabbitMQ Listeners with Testcontainers

@ServiceConnection on a Testcontainers RabbitMQContainer starts RabbitMQ in Docker and points spring.rabbitmq.* at it, overriding the host, port and credentials from application.properties. The Testcontainers tutorial explains the setup.

OrderFlowIntegrationTest.java
package com.javabytechie.shop;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.rabbitmq.RabbitMQContainer;

import com.javabytechie.shop.inventory.StockService;
import com.javabytechie.shop.order.OrderEventPublisher;
import com.javabytechie.shop.order.OrderPlacedEvent;

@SpringBootTest
@Testcontainers
class OrderFlowIntegrationTest {

    @Container
    @ServiceConnection
    static RabbitMQContainer rabbit = new RabbitMQContainer("rabbitmq:4.2-management");

    @Autowired
    private OrderEventPublisher publisher;

    @Autowired
    private StockService stockService;

    @Test
    void reservesStockWhenOrderIsPublished() {
        int before = stockService.availableStock("KB-100");

        publisher.publish(order("order-test-1", "KB-100", 2));

        await().atMost(Duration.ofSeconds(10))
                .untilAsserted(() -> assertThat(stockService.availableStock("KB-100")).isEqualTo(before - 2));
    }

    @Test
    void reservesStockOnlyOnceForDuplicateEvents() {
        int before = stockService.availableStock("MS-200");
        OrderPlacedEvent event = order("order-test-2", "MS-200", 3);

        publisher.publish(event);
        publisher.publish(event);

        // The stock must drop by 3 and then stay there, even after the duplicate is consumed
        await().atMost(Duration.ofSeconds(10))
                .during(Duration.ofSeconds(2))
                .until(() -> stockService.availableStock("MS-200") == before - 3);
    }

    private static OrderPlacedEvent order(String orderId, String sku, int quantity) {
        return new OrderPlacedEvent(orderId, "C-TEST", sku, quantity, new BigDecimal("10.00"), Instant.now());
    }
}

Each test reads the stock before publishing and asserts the change after it. The listener runs on another thread, so Awaitility polls until the expected value appears. The second test publishes the same event twice, and during(...) checks that the stock stays at the expected value for two seconds, proving the duplicate was ignored.

Console
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0

11. When to Use RabbitMQ and When to Avoid It

Good use casesPoor use cases
Background jobs: emails, PDFs, image resizingRequests where the caller needs the result immediately
Routing one event to different consumers by patternReplaying months of old events (Kafka fits better)
Smoothing traffic spikes with a work queueVery high-volume streams of millions of events per second
Request/reply and per-message delays or prioritiesListeners that all live in the same application

RabbitMQ is a smart router with queues that empty as messages are consumed. Kafka is a log that keeps events for replay. The Spring Boot Kafka tutorial and the Kafka vs RabbitMQ comparison help you choose.

12. Best Practices

  • Declare your topology in code. Exchanges, queues and bindings as beans make every environment identical and prevent unroutable messages.
  • Use durable queues for business events. Spring AMQP already sends persistent messages by default; the queue must survive restarts too.
  • Give every work queue a dead letter exchange. Failed messages should be parked for inspection, not deleted or looped forever.
  • Turn on listener retries with a short back-off. Two or three quick retries cover brief glitches without blocking the consumer for long.
  • Confirm important publishes. Use publisher-confirm-type=correlated and wait for the confirm before telling the caller the work is accepted.
  • Make every listener idempotent. Expect redelivery, and skip IDs you have already processed.
  • Send JSON with a converter bean. Use JacksonJsonMessageConverter and trust only your own event packages.
  • Use a dedicated broker user. Never rely on guest outside local development.
  • Test against a real broker. Use Testcontainers with @ServiceConnection for listener tests.

13. Summary

Spring Boot integration with RabbitMQ takes a few steps: add spring-boot-starter-amqp, declare a topic exchange, durable queues and bindings as beans, register a JacksonJsonMessageConverter, publish with RabbitTemplate while waiting for publisher confirms, and consume with @RabbitListener. Enable short listener retries and give each work queue a dead letter exchange, so failures end up in a dead letter queue instead of looping or vanishing, then prove the flow with a Testcontainers test. The main recommendation: design for failure from the start, with durable queues, confirms, idempotent listeners and a dead letter queue on every work queue.