Spring Boot Integration with RabbitMQ
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.
| Exchange type | How it routes | Typical use |
|---|---|---|
direct | Routing key must equal the binding key exactly | Commands to one specific queue |
topic | Pattern match on dot-separated words: * matches one word, # matches zero or more | Events that different consumers filter differently |
fanout | Ignores the routing key and copies to every bound queue | Broadcasts, such as cache-clear signals |
headers | Matches message headers instead of the routing key | Rare; 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
<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.
- JSON converter: Spring AMQP 4 (used by Spring Boot 4) is built for Jackson 3. Use
JacksonJsonMessageConverter. TheJackson2JsonMessageConverterfrom 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 bymax-retries(retries after the first attempt). - Test starter: Spring Boot 4 adds
spring-boot-starter-amqp-test, which also bringsspring-boot-starter-test. - Testcontainers 2: the RabbitMQ container class is
org.testcontainers.rabbitmq.RabbitMQContainer, in thetestcontainers-rabbitmqmodule.
4.2 A Local RabbitMQ Broker
services:
rabbitmq:
image: rabbitmq:4.2-management
environment:
RABBITMQ_DEFAULT_USER: shop
RABBITMQ_DEFAULT_PASS: shop-secret
ports:
- "5672:5672"
- "15672:15672"
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
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).
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.
@RabbitListener(queues = "inventory.order-placed")
public void onOrderPlaced(OrderPlacedEvent event) {
stockService.reserve(event.orderId(), event.sku(), event.quantity());
}
| Attribute | What it does |
|---|---|
queues | Names of existing queues to consume from |
concurrency | Number of consumer threads, for example "2" or "2-5" |
id | Listener ID, useful for starting or stopping it at runtime |
ackMode | Overrides the container's acknowledge mode for this listener |
containerFactory | Bean name of a custom listener container factory |
bindings | Declares 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.
@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
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);
}
}
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
package com.javabytechie.shop.order;
import java.math.BigDecimal;
public record PlaceOrderRequest(String customerId, String sku, int quantity, BigDecimal totalAmount) {
}
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) {
}
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.
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);
}
}
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);
}
}
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
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);
}
}
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);
}
}
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());
}
}
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
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
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}'
HTTP/1.1 202
{"orderId":"3e9a51c2-7d4b-4f1e-a6c8-2b5d9e0f1a37","status":"PENDING"}
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:
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}'
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
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:
- Retries in the consumer.
spring.rabbitmq.listener.simple.retry.enabled=truewraps the listener in a retry interceptor. Withmax-retries=2andinitial-interval=1s, a failing message gets three attempts, one second apart, inside the same delivery. - 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 withx-dead-letter-exchange, the broker moves it toshop.orders.dlx, which routes it toinventory.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
| Property | Default | What it controls |
|---|---|---|
spring.rabbitmq.host / port | localhost / 5672 | Broker address |
spring.rabbitmq.username / password | guest / guest | Login; guest works only from localhost |
spring.rabbitmq.virtual-host | broker default (/) | Isolated namespace on a shared broker |
spring.rabbitmq.publisher-confirm-type | none | correlated enables per-message confirms |
spring.rabbitmq.listener.simple.acknowledge-mode | auto | auto, manual or none |
spring.rabbitmq.listener.simple.prefetch | 250 | Unacknowledged messages per consumer |
spring.rabbitmq.listener.simple.concurrency | 1 | Consumer threads per listener |
spring.rabbitmq.listener.simple.default-requeue-rejected | true | Whether a failed message goes back to the queue |
spring.rabbitmq.listener.simple.retry.enabled | false | Retry failing listener calls |
spring.rabbitmq.listener.simple.retry.max-retries | 3 | Retries after the first attempt |
spring.rabbitmq.listener.simple.retry.initial-interval | 1000ms | Wait 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 symptom | Cause | Fix |
|---|---|---|
| The same message is processed in an endless loop | The listener throws and the message is requeued every time | Enable listener retries and a dead letter exchange, or set default-requeue-rejected=false |
ACCESS_REFUSED - Login was refused | The guest user can only connect from localhost, or the credentials are wrong | Create a dedicated user, as the Compose file does |
PRECONDITION_FAILED - inequivalent arg 'x-dead-letter-exchange' | The queue already exists with different arguments | Delete the queue and let the app recreate it, or manage arguments with broker policies |
| Messages disappear after retries | Rejected messages are deleted when the queue has no dead letter exchange | Declare the queue with deadLetterExchange(...) |
Listener gets byte[] or fails to convert | No JSON converter bean, or the sender used a different content type | Declare JacksonJsonMessageConverter on both sides |
| Messages lost after a broker restart | The queue was non-durable | Use QueueBuilder.durable(...) |
| Stock reserved twice | At-least-once redelivery after a crash | Make 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.
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.
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
11. When to Use RabbitMQ and When to Avoid It
| Good use cases | Poor use cases |
|---|---|
| Background jobs: emails, PDFs, image resizing | Requests where the caller needs the result immediately |
| Routing one event to different consumers by pattern | Replaying months of old events (Kafka fits better) |
| Smoothing traffic spikes with a work queue | Very high-volume streams of millions of events per second |
| Request/reply and per-message delays or priorities | Listeners 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=correlatedand 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
JacksonJsonMessageConverterand trust only your own event packages. - Use a dedicated broker user. Never rely on
guestoutside local development. - Test against a real broker. Use Testcontainers with
@ServiceConnectionfor 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.
Frequently Asked Questions (FAQ)
Q1. What is the difference between RabbitMQ and Kafka in Spring Boot?
RabbitMQ is a message broker that routes each message through exchanges into queues and deletes it once a consumer acknowledges it. Kafka is a distributed log that keeps events for a retention period so many consumers can read and replay them. In Spring Boot you use RabbitTemplate and @RabbitListener for RabbitMQ, and KafkaTemplate and @KafkaListener for Kafka. Choose RabbitMQ for flexible routing and work queues, and Kafka for high-volume event streams and replay.
Q2. Why does my @RabbitListener keep receiving the same message over and over?
By default, a message whose listener throws an exception is rejected and requeued, so it is delivered again straight away and fails again. Enable listener retries with spring.rabbitmq.listener.simple.retry.enabled=true, or set spring.rabbitmq.listener.simple.default-requeue-rejected=false. Then give the queue a dead letter exchange so the failed message is kept for inspection instead of deleted.
Q3. Is Jackson2JsonMessageConverter deprecated in Spring Boot 4?
Yes. Spring AMQP 4, which Spring Boot 4 uses, deprecated the Jackson 2 based converters in favour of Jackson 3 versions. Replace Jackson2JsonMessageConverter with JacksonJsonMessageConverter from the same org.springframework.amqp.support.converter package. Spring Boot picks up a MessageConverter bean automatically for both the template and the listeners.
Q4. Does RabbitMQ guarantee message order?
A single queue delivers messages in the order they were published, as long as there is one consumer and no message is requeued. With several consumers, or with concurrency above 1, messages are processed in parallel and can finish out of order. If order matters for a key such as an order ID, use one consumer per queue, or route each key to its own queue.
Q5. What happens to messages when my Spring Boot application is down?
Messages wait in the queue until a consumer connects again, as long as the queue is durable and the messages are persistent, which is Spring AMQP's default. When the application restarts, the listener container reconnects and processes the backlog. A non-durable or auto-delete queue may be gone when the app returns, so use durable queues for business events.
Q6. Should I use classic or quorum queues?
Quorum queues replicate their data across several RabbitMQ nodes and are the recommended choice for important data in a cluster. Classic queues are fine for a single node or for temporary data. Classic mirrored queues were removed in RabbitMQ 4.0, so new clustered setups should use quorum queues. In Spring AMQP, declare one with QueueBuilder.durable(name).quorum().build().
Q7. Why do I get ACCESS_REFUSED when connecting with the guest user?
RabbitMQ allows the default guest user to connect only from localhost. When your application runs in another container or on another machine, the login is refused. Create a dedicated user, for example with RABBITMQ_DEFAULT_USER and RABBITMQ_DEFAULT_PASS in Docker, and set spring.rabbitmq.username and spring.rabbitmq.password to match.