RabbitMQ is a popular message broker. You can work with it from Java directly through its Java client, but that means a lot of low-level code: managing connections, threads, ack/nack by hand. Spring AMQP (the spring-rabbit library) takes that code off your hands and gives you a convenient Spring way to send and receive messages.
Adding the dependency
A single Gradle dependency pulls in everything you need:
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-amqp")
}
Basic configuration in application.yml:
spring:
rabbitmq:
host: rabbit
port: 5672
username: billing-service
password: ${RABBIT_PASSWORD}
virtual-host: /prod
Spring Boot automatically creates the connection and wires up all the beans you need.
How to send a message: RabbitTemplate
Previously, to publish a message you had to open a channel by hand, serialize the object, and call AMQP protocol methods. Spring AMQP does this in a single call.
The main tool for sending is RabbitTemplate. Spring Boot creates it automatically — just inject it as a dependency:
@Component
@RequiredArgsConstructor
public class OrderEventPublisher {
private final RabbitTemplate rabbit;
public void publish(OrderCreatedEvent event) {
rabbit.convertAndSend("orders", "order.created", event);
}
}
convertAndSend(exchange, routingKey, payload) is the main method. Three parameters:
"orders"— the exchange name (where we send to);"order.created"— the routing key (the exchange uses it to decide which queue to route to);event— the object that becomes the message body.
Why change the serialization
By default Spring AMQP serializes the object with Java serialization. That works, but this approach has a problem: another service (or another language) won't be able to read the message. The Java serialization format is understood only by Java.
The solution is to switch to JSON. To do that you declare a converter and hand it to the template:
@Configuration
public class RabbitConfig {
@Bean
MessageConverter jacksonConverter(ObjectMapper mapper) {
return new Jackson2JsonMessageConverter(mapper);
}
@Bean
RabbitTemplate rabbitTemplate(ConnectionFactory cf, MessageConverter converter) {
var t = new RabbitTemplate(cf);
t.setMessageConverter(converter);
return t;
}
}
After this the payload goes out as JSON. Spring automatically adds the content-type: application/json header — the receiver will know how to deserialize it.
How to receive a message: @RabbitListener
The easiest way to subscribe to a queue is with the @RabbitListener annotation:
@Component
public class OrderEventListener {
@RabbitListener(queues = "orders.fulfillment", concurrency = "3-10")
public void handle(OrderCreatedEvent event) {
// handle the event
}
}
Spring starts a thread pool that reads from the queue and calls your method. The concurrency = "3-10" parameter sets a range: at least 3 threads always running, and up to 10 as the load grows.
Spring deserializes the OrderCreatedEvent object automatically from JSON if a MessageConverter is configured.
How to declare a queue, exchange, and binding
Usually a queue is created manually in RabbitMQ or through Infrastructure as Code. But Spring AMQP can create them when the application starts — this is handy for development and simple setups:
@Configuration
public class RabbitTopology {
@Bean
Queue ordersFulfillment() {
return QueueBuilder
.durable("orders.fulfillment")
.quorum()
.withArgument("x-dead-letter-exchange", "orders.dlx")
.build();
}
@Bean
DirectExchange ordersExchange() {
return new DirectExchange("orders", true, false);
}
@Bean
Binding binding(Queue ordersFulfillment, DirectExchange ordersExchange) {
return BindingBuilder
.bind(ordersFulfillment)
.to(ordersExchange)
.with("order.created");
}
}
Spring sees these beans and, at startup, checks whether they exist in the broker. If not, it creates them.
What happens on an error
By default, if your listener method throws an exception, Spring returns the message back to the queue — and it arrives again immediately. This creates an infinite loop: the message is processed, fails, is returned, and is processed again.
To avoid this, you configure two mechanisms together: retry (repeated attempts) and a Dead Letter Exchange (the place where messages go after all failed attempts).
Retries
If the error is temporary (the database is unavailable for a second, an external service responded with 503), it makes sense to try a few times with a pause between attempts:
@Bean
RetryOperationsInterceptor retryInterceptor() {
return RetryInterceptorBuilder.stateless()
.maxAttempts(4)
.backOffOptions(1000L, 2.0, 30000L) // start at 1s, double each time, cap at 30s
.recoverer(new RejectAndDontRequeueRecoverer())
.build();
}
@Bean
SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
ConnectionFactory cf, MessageConverter converter) {
var factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(cf);
factory.setMessageConverter(converter);
factory.setAdviceChain(retryInterceptor());
factory.setDefaultRequeueRejected(false);
return factory;
}
What happens here:
- Spring makes 4 attempts with an exponential pause (1s, 2s, 4s).
- After the 4th failed attempt,
RejectAndDontRequeueRecoverertells the broker "don't return this message" — and it goes to the Dead Letter Exchange. setDefaultRequeueRejected(false)— a safeguard for exceptions that retry didn't intercept.
Dead Letter Exchange
A Dead Letter Exchange (DLX) is a special exchange in RabbitMQ where "dead" messages end up: the ones that couldn't be processed. From there you can analyze them, send a notification, or try to process them a different way.
Configuring a DLX at the queue level:
@Bean
Queue ordersMain() {
return QueueBuilder
.durable("orders.fulfillment")
.quorum()
.withArgument("x-dead-letter-exchange", "orders.dlx")
.withArgument("x-dead-letter-routing-key", "order.failed")
.build();
}
@Bean DirectExchange ordersDlx() {
return new DirectExchange("orders.dlx", true, false);
}
@Bean
Queue ordersDlq() {
return QueueBuilder.durable("orders.dlq").quorum().build();
}
@Bean
Binding dlqBinding(Queue ordersDlq, DirectExchange ordersDlx) {
return BindingBuilder.bind(ordersDlq).to(ordersDlx).with("order.failed");
}
The flow on an error: main queue → exception → reject without requeue → DLX → orders.dlq queue.
The orders.dlq queue usually has a separate listener that logs the problem, stores the message in a database for manual review, or sends an alert:
@RabbitListener(queues = "orders.dlq")
public void inspect(
OrderCreatedEvent event,
@Header("x-death") List<Map<String, Object>> deaths) {
log.error("Failed to process orderId={}, attempts={}",
event.orderId(), deaths.size());
}
The x-death header is added by the broker — it holds the history of attempts: how many times the message was returned and for what reason.
Acknowledgement modes
RabbitMQ works on the principle of acknowledgements: the broker holds a message until the consumer says "got it" (ack) or "couldn't handle it" (nack). This protects against message loss.
Spring AMQP supports three modes:
AUTO(default) — Spring sends the ack itself after the method returns successfully; on an exception, it sends a nack. The simplest mode.MANUAL— you manage ack/nack yourself through theChannelobject. Gives full control but complicates the code.NONE— the broker considers the message delivered immediately, without waiting for acknowledgement. Not suitable for critical data.
The standard recipe: AUTO + setDefaultRequeueRejected(false) + DLX. This is enough for most cases.
Message headers
Technical metadata (a request identifier, an event version) is convenient to pass through message headers rather than mixing it with business data in the body:
// When sending
public void publish(OrderEvent event, String correlationId) {
var message = MessageBuilder
.withBody(jsonOf(event))
.setContentType("application/json")
.setHeader("X-Correlation-ID", correlationId)
.setHeader("X-Event-Version", "2")
.build();
rabbit.send("orders", "order.created", message);
}
// When receiving
@RabbitListener(queues = "orders.fulfillment")
public void handle(
@Payload OrderEvent event,
@Header("X-Correlation-ID") String correlationId) {
MDC.put("correlationId", correlationId);
// ...
}
A complete example
Here is what a minimal working setup looks like: the topology, the listener, and the dead-letter handler together:
@Configuration
public class OrdersTopology {
@Bean DirectExchange ordersEx() { return new DirectExchange("orders", true, false); }
@Bean DirectExchange ordersDlx() { return new DirectExchange("orders.dlx", true, false); }
@Bean
Queue ordersFulfillment() {
return QueueBuilder.durable("orders.fulfillment").quorum()
.withArgument("x-dead-letter-exchange", "orders.dlx")
.withArgument("x-dead-letter-routing-key", "order.failed")
.build();
}
@Bean Queue ordersDlq() { return QueueBuilder.durable("orders.dlq").quorum().build(); }
@Bean
Binding bindFulfillment(Queue ordersFulfillment, DirectExchange ordersEx) {
return BindingBuilder.bind(ordersFulfillment).to(ordersEx).with("order.created");
}
@Bean
Binding bindDlq(Queue ordersDlq, DirectExchange ordersDlx) {
return BindingBuilder.bind(ordersDlq).to(ordersDlx).with("order.failed");
}
}
@Component
@RequiredArgsConstructor
class OrderListener {
private final OrderHandler handler;
@RabbitListener(queues = "orders.fulfillment", concurrency = "3-10")
public void on(OrderCreatedEvent event) {
handler.process(event);
}
}
@Component
class DlqInspector {
@RabbitListener(queues = "orders.dlq")
public void inspect(
OrderCreatedEvent event,
@Header("x-death") List<Map<String, Object>> deaths) {
log.error("Failed to process: orderId={}", event.orderId());
}
}
In short
- Spring AMQP (
spring-boot-starter-amqp) wraps the RabbitMQ client and removes the low-level code. - RabbitTemplate is for sending; the main method is
convertAndSend(exchange, routingKey, payload). - By default serialization is Java; for cross-service communication you need
Jackson2JsonMessageConverter. @RabbitListeneris for receiving; theconcurrencyparameter sets the thread pool size.- On an error without any configuration you get an infinite loop — you need
setDefaultRequeueRejected(false)and a DLX. - RetryTemplate makes several attempts with a pause; once they run out, the message goes to a Dead Letter Exchange.
- DLX/DLQ is configured via
QueueBuilder.withArgument("x-dead-letter-exchange", ...). - The standard acknowledgement setup:
AUTO+requeue=false+ DLX.
What to read next
- The AMQP protocol — the delivery model: exchange, queue, binding, ack.
- RabbitMQ in production — Quorum Queues, clustering, monitoring.
- Messaging patterns over AMQP — work queue, pub/sub, RPC, idempotent consumer.