RabbitMQ with Spring AMQP
Declare exchanges and queues, publish JSON with RabbitTemplate, consume with @RabbitListener, and stop a poison message from looping forever.
@Bean Queue / TopicExchange / Binding
rabbitTemplate.convertAndSend(exchange, routingKey, payload);
@RabbitListener(queues = "queue.name") void handle(Payload p) { }Add spring-boot-starter-amqp and Spring Boot creates a connection factory, a RabbitTemplate for sending and a listener container factory for @RabbitListener methods. Connection settings live under spring.rabbitmq.*; locally the defaults point at localhost:5672 with the guest user.
Exchanges, queues and bindings
Producers never write to a queue directly. They publish to an exchange with a routing key, and bindings decide which queues receive a copy. A topic exchange matches keys with wildcards: a queue bound with order.* receives order.placed and order.cancelled. Declare Queue, TopicExchange and Binding beans (from org.springframework.amqp.core) and Spring creates them on the broker when the first connection opens.
Sending JSON
The default converter only handles strings, byte arrays and Serializable objects. Declare a JacksonJsonMessageConverter bean, the Jackson 3 based converter in org.springframework.amqp.support.converter, and Spring Boot applies it to both the template and the listener containers. Then convertAndSend(exchange, routingKey, record) sends JSON and a listener method can take the record as its parameter.
rabbit.convertAndSend("orders", "order.placed", event);
@RabbitListener(queues = "billing.order-placed")
void on(OrderPlaced event) { ... }Acknowledgements and failures
Spring acknowledges the message when the listener method returns and rejects it when it throws. By default a rejected message is requeued, so a message that always fails, a poison message, is redelivered in a tight loop that burns CPU and fills the logs. Two fixes belong in every application:
- Give the queue a dead-letter exchange so rejected messages go to a parking queue you can inspect.
- Enable listener retry with
spring.rabbitmq.listener.simple.retry.enabled=true. After the retries are used up the message is rejected without requeueing, which sends it to the dead-letter exchange. ThrowingAmqpRejectAndDontRequeueExceptionrejects a message without requeueing. The retry policy Spring Boot builds retries every exception, this one included, so to skip retries for errors that will never succeed, such as invalid data, narrow the policy with aRabbitListenerRetrySettingsCustomizerbean.
Production notes
Use durable quorum queues for anything you cannot lose; classic non-durable queues vanish when the broker restarts. Set prefetch to limit how many unacknowledged messages one consumer holds, so a slow instance does not hoard work. Keep listener methods idempotent, because a consumer that dies before acknowledging hands the message to someone else.
Example
import java.math.BigDecimal;
import java.util.UUID;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.QueueBuilder;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
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;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
record OrderPlaced(UUID eventId, long orderId, BigDecimal total) {
}
public class OrderMessagingConfig {
static final String EXCHANGE = "orders";
static final String BILLING_QUEUE = "billing.order-placed";
TopicExchange ordersExchange() {
return new TopicExchange(EXCHANGE);
}
Queue billingQueue() {
return QueueBuilder.durable(BILLING_QUEUE)
.quorum()
.deadLetterExchange("orders.dlx")
.build();
}
Binding billingBinding(Queue billingQueue, TopicExchange ordersExchange) {
return BindingBuilder.bind(billingQueue).to(ordersExchange).with("order.placed");
}
MessageConverter jsonMessageConverter() {
return new JacksonJsonMessageConverter();
}
}
class OrderEventPublisher {
private final RabbitTemplate rabbit;
OrderEventPublisher(RabbitTemplate rabbit) {
this.rabbit = rabbit;
}
void orderPlaced(long orderId, BigDecimal total) {
rabbit.convertAndSend(OrderMessagingConfig.EXCHANGE, "order.placed",
new OrderPlaced(UUID.randomUUID(), orderId, total));
}
}
class BillingListener {
(queues = OrderMessagingConfig.BILLING_QUEUE)
void onOrderPlaced(OrderPlaced event) {
// create the invoice; throwing here triggers retry, then dead-lettering
}
}
When to use it
- Distribute PDF invoice generation across four worker instances that compete for messages on one queue.
- Route order.placed to billing and shipping queues, and order.cancelled only to shipping, with one topic exchange.
- Park malformed partner messages in a dead-letter queue and alert support instead of crashing the consumer loop.
More examples
Connection, retry and prefetch settings
Retry happens inside the listener container, on the same consumer thread, with a growing delay between attempts. In Spring Boot 4 the count is <code>max-retries</code>, the retries after the first attempt; the older <code>max-attempts</code> property was replaced in Spring Boot 4.0 and is no longer supported. When retries run out the message is rejected without requeueing, so it reaches the queue's dead-letter exchange.
spring.rabbitmq.host=${RABBITMQ_HOST:localhost}
spring.rabbitmq.port=5672
spring.rabbitmq.username=${RABBITMQ_USER:guest}
spring.rabbitmq.password=${RABBITMQ_PASSWORD:guest}
spring.rabbitmq.virtual-host=/
spring.rabbitmq.listener.simple.prefetch=20
spring.rabbitmq.listener.simple.concurrency=2
spring.rabbitmq.listener.simple.max-concurrency=8
spring.rabbitmq.listener.simple.retry.enabled=true
spring.rabbitmq.listener.simple.retry.max-retries=3
spring.rabbitmq.listener.simple.retry.initial-interval=1s
spring.rabbitmq.listener.simple.retry.multiplier=2
Rejecting a message that can never succeed
Retrying a message with a negative amount only delays the inevitable. The listener throws <code>AmqpRejectAndDontRequeueException</code>, and the customizer tells Spring Boot's retry policy not to retry when that exception appears anywhere in the cause chain (the container wraps listener exceptions, so checking only the top level would miss it). The message is then rejected without requeueing and moves straight to the dead-letter queue, while transient errors such as timeouts still get their retries.
import java.math.BigDecimal;
import java.util.UUID;
import org.springframework.amqp.AmqpRejectAndDontRequeueException;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.boot.amqp.autoconfigure.RabbitListenerRetrySettingsCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
record PaymentRequested(UUID eventId, long orderId, BigDecimal amount, String currency) {
}
@Configuration
class ListenerRetryConfig {
@Bean
RabbitListenerRetrySettingsCustomizer skipRetryForRejectedMessages() {
return settings -> settings.setExceptionPredicate(ex -> !causedByReject(ex));
}
private static boolean causedByReject(Throwable ex) {
for (Throwable t = ex; t != null; t = t.getCause()) {
if (t instanceof AmqpRejectAndDontRequeueException) {
return true;
}
}
return false;
}
}
@Component
public class PaymentListener {
@RabbitListener(queues = "payments.requested")
void handle(PaymentRequested request) {
if (request.amount() == null || request.amount().signum() <= 0) {
throw new AmqpRejectAndDontRequeueException("Invalid amount for order " + request.orderId());
}
charge(request);
}
private void charge(PaymentRequested request) {
// call the payment provider; a timeout here is worth retrying
}
}A dead-letter exchange and a parking queue
Every queue declared with <code>deadLetterExchange("orders.dlx")</code> sends rejected messages here. A fanout exchange ignores the routing key, so one parking queue collects dead letters from every source queue. No listener consumes <code>orders.parking</code> on purpose: a consumer would acknowledge and remove the messages you need to inspect. RabbitMQ adds an <code>x-death</code> header naming the original queue and the reason, which you can read in the management UI. In production, alert on the parking queue's depth and replay messages by hand once the bug is fixed.
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.FanoutExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.QueueBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class DeadLetterConfig {
@Bean
FanoutExchange deadLetterExchange() {
return new FanoutExchange("orders.dlx");
}
@Bean
Queue parkingQueue() {
return QueueBuilder.durable("orders.parking").quorum().build();
}
@Bean
Binding parkingBinding(Queue parkingQueue, FanoutExchange deadLetterExchange) {
return BindingBuilder.bind(parkingQueue).to(deadLetterExchange);
}
}
Discussion