When you want to use RabbitMQ for asynchronous communication between microservices, the first hurdle is understanding the relationship between Exchanges, Queues, and Bindings. Since the routing model differs from Kafka, grasping the concepts before writing code will make things much smoother. This article focuses on working code using spring-amqp.

Core Concepts of RabbitMQ and AMQP

RabbitMQ is a message broker that implements the AMQP protocol. The message flow looks like this:

Producer → Exchange → Binding (RoutingKey) → Queue → Consumer

The Producer simply sends a message to an Exchange — which Queue it reaches is determined by the Exchange type and Bindings.

  • Direct: Delivers to the Queue whose RoutingKey is an exact match
  • Fanout: Ignores the RoutingKey and delivers to all bound Queues
  • Topic: Wildcard matching using * (one word) and # (zero or more words)

Starting RabbitMQ with Docker Compose

The rabbitmq:3-management image with the management UI is convenient.

services:
  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - "5672:5672"
      - "15672:15672"
    environment:
      RABBITMQ_DEFAULT_USER: guest
      RABBITMQ_DEFAULT_PASS: guest

After running docker compose up -d, you can check the state of Exchanges, Queues, and Bindings in the management UI at http://localhost:15672 (default login: guest/guest).

Adding Dependencies and Connection Configuration

Add the following to pom.xml. Note that spring-retry is included as a transitive dependency of spring-boot-starter-amqp, so no separate entry is needed.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

The application.properties configuration is straightforward.

spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest

AutoConfiguration automatically creates a ConnectionFactory and RabbitTemplate for you.

Defining Exchange, Queue, and Binding as Beans

The standard approach is to define them together in a @Configuration class.

@Configuration
public class RabbitMQConfig {

    public static final String EXCHANGE    = "order.direct";
    public static final String QUEUE       = "order.queue";
    public static final String ROUTING_KEY = "order.created";

    @Bean
    public DirectExchange orderExchange() {
        return new DirectExchange(EXCHANGE);
    }

    @Bean
    public Queue orderQueue() {
        return QueueBuilder.durable(QUEUE).build();
    }

    @Bean
    public Binding orderBinding(Queue orderQueue, DirectExchange orderExchange) {
        return BindingBuilder.bind(orderQueue)
                .to(orderExchange).with(ROUTING_KEY);
    }

    @Bean
    public Jackson2JsonMessageConverter jsonConverter() {
        return new Jackson2JsonMessageConverter();
    }

    @Bean
    public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory,
                                          Jackson2JsonMessageConverter converter) {
        RabbitTemplate template = new RabbitTemplate(connectionFactory);
        template.setMessageConverter(converter);
        return template;
    }
}

Configuring Jackson2JsonMessageConverter enables sending and receiving POJOs as JSON.

Implementing the Producer

@Service
@RequiredArgsConstructor
public class OrderProducer {

    private final RabbitTemplate rabbitTemplate;

    public void send(OrderMessage message) {
        rabbitTemplate.convertAndSend(
            RabbitMQConfig.EXCHANGE,
            RabbitMQConfig.ROUTING_KEY,
            message
        );
    }
}

Implementing the Consumer (Manual Ack)

@Component
public class OrderConsumer {

    @RabbitListener(queues = RabbitMQConfig.QUEUE, ackMode = "MANUAL")
    public void consume(OrderMessage message,
                        com.rabbitmq.client.Channel channel,
                        @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
        try {
            process(message);
            channel.basicAck(tag, false);
        } catch (Exception e) {
            channel.basicNack(tag, false, false); // requeue=false → forward to DLQ
        }
    }
}

Setting the third argument of basicNack (requeue) to false forwards the message to the DLQ.

Note: Container-level retry (via Advice, described below) and manual Ack should not be used together as a general rule. If you use container-level retry, leave ackMode at the default (AUTO).

Fanout and Topic Exchange Examples

Fanout Exchange is useful when you want to broadcast to all Consumers, such as for notification delivery.

@Bean
public FanoutExchange fanoutExchange() {
    return new FanoutExchange("notification.fanout");
}

@Bean
public Binding emailBinding(Queue emailQueue, FanoutExchange fanout) {
    return BindingBuilder.bind(emailQueue).to(fanout);
}

Topic Exchange is effective when wildcard routing is needed. For example, the pattern log.error.# matches log.error.app.db regardless of how many subsequent words follow.

@Bean
public TopicExchange topicExchange() {
    return new TopicExchange("log.topic");
}

@Bean
public Binding errorBinding(Queue errorQueue, TopicExchange topic) {
    return BindingBuilder.bind(errorQueue).to(topic).with("log.error.#");
}
ExchangeRoutingKeyPrimary Use Case
DirectExact matchTask queues
FanoutIgnoredBroadcast notifications
TopicWildcardLog aggregation, event filtering

Configuring Dead Letter Exchange (DLX)

This mechanism routes failed messages to a DLQ. Update the orderQueue() definition as follows (replacing the earlier definition).

// Replace orderQueue() with DLX argument support
@Bean
public Queue orderQueue() {
    return QueueBuilder.durable(RabbitMQConfig.QUEUE)
            .withArgument("x-dead-letter-exchange", "order.dlx")
            .build();
}

@Bean
public DirectExchange dlxExchange() {
    return new DirectExchange("order.dlx");
}

@Bean
public Queue dlqQueue() {
    return QueueBuilder.durable("order.dlq").build();
}

@Bean
public Binding dlqBinding(Queue dlqQueue, DirectExchange dlxExchange) {
    return BindingBuilder.bind(dlqQueue).to(dlxExchange).with(RabbitMQConfig.ROUTING_KEY);
}

Calling basicNack(tag, false, false) on the Consumer side will forward the message to this DLQ.

Configuring Retry Policy

Configure an Advice using RetryInterceptorBuilder on the container factory. After a maximum of 3 retry attempts, failed messages are forwarded to the DLQ.

Note: This retry operates at the container level (ackMode = AUTO). Do not use it together with manual Ack listeners as a general rule.

@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
        ConnectionFactory connectionFactory, Jackson2JsonMessageConverter converter) {
    SimpleRabbitListenerContainerFactory factory =
            new SimpleRabbitListenerContainerFactory();
    factory.setConnectionFactory(connectionFactory);
    factory.setMessageConverter(converter);
    factory.setAdviceChain(
        RetryInterceptorBuilder.stateless()
            .maxAttempts(3)
            .backOffOptions(1000L, 2.0, 10000L)
            .build()
    );
    return factory;
}

For fault tolerance when calling external APIs, you can also combine this with Resilience4j circuit breakers. See How to Implement a Resilience4j Circuit Breaker in Spring Boot for details.

When to Use Kafka vs. RabbitMQ

AspectKafkaRabbitMQ
ThroughputVery highModerate
RoutingConsumer Groups onlyFlexible via Exchange/Binding
Ordering guaranteeWithin a partitionPer Queue
Message retentionRetained for configured periodDeleted after Ack

A common rule of thumb is to use Kafka for high-volume log collection and event sourcing, and RabbitMQ for task queues and notification delivery that requires complex routing. For Kafka implementation, see How to Implement a Kafka Producer and Consumer in Spring Boot.

For loose coupling between services within the same JVM, ApplicationEvent is another option worth considering. Check out How to Implement Loosely Coupled Async Processing with Spring Boot ApplicationEvent as well.

Summary

This article covered the key points of implementing RabbitMQ with spring-amqp. Once you learn the pattern of defining Exchanges, Queues, and Bindings as Beans, switching between Direct, Fanout, and Topic becomes straightforward. Combining DLX with retry policies prevents message loss during failures, so be sure to configure these before deploying to production.

For async processing in general, Mastering Async Processing with @Async in Spring Boot is also worth a read.