Spring Boot and RabbitMQ: Exchanges, Queues, Bindings and a Working Dead-Letter Queue
All four AMQP exchange types against a real broker, manual acknowledgement, and a dead-letter path that actually works — including the three triggers that fill a DLQ, the one that never does, and the message that vanishes with no error at all. Why publisher-returns and mandatory are two settings in two different places, why basicNack with requeue=true is an infinite loop that never dead-letters and never raises a queue-depth alarm, what x-death.reason tells you that nothing else does, and why changing a queue’s TTL is a migration rather than a config edit.
RabbitMQ has a reputation for being unpredictable. It is not; it is precise about things people assume are lenient, and silent about things people assume would be errors.
Three of those are expensive enough to be worth an article: a message published to an exchange with no matching binding is discarded with no error and no log line; a basicNack with requeue=true is an infinite loop that never touches your dead-letter queue and never raises a depth alarm; and a queue’s arguments are immutable, so changing a TTL is a migration rather than a config edit.
Everything below ran against a real broker. All four exchange types, manual acknowledgement, a DLX exercised through both rejection and TTL expiry, and the transcripts to go with them.
Verified against. JDK 25 (Temurin 25.0.4.1+1) · Spring Boot 4.1.1 · Spring AMQP / Spring Rabbit 4.1.1 · com.rabbitmq:amqp-client5.30.0 (Boot-managed; Central has 5.35.0) · Testcontainers 2.0.5. The broker that produced the committed transcripts is RabbitMQ 3.10.25, because the capture machine has no Docker and no root and that is the newest release pairing with the Erlang runtime available to it. Everything exercised here — the four exchange types, x-dead-letter-exchange, x-message-ttl, x-death, manual ack, mandatory returns, PRECONDITION_FAILED — is AMQP 0-9-1 behaviour unchanged in RabbitMQ 4.x. The one 4.x difference worth knowing is that quorum queues are the default queue type; nothing here declares a type, so the topology is valid on both. The companion project ships a Testcontainers configuration for rabbitmq:4.1-management.
Boot 4 moved every auto-configuration into a per-technology module. RabbitMQ’s lives in spring-boot-amqp, and a bare org.springframework.amqp:spring-rabbit dependency does not bring it — you get no RabbitTemplate, no RabbitAdmin, no listener container factory, and a context that starts cleanly. Use spring-boot-starter-amqp.
Then, unlike Spring Kafka, Boot gives you no JSON converter. The default is SimpleMessageConverter, which handles String, byte[] and Serializable and refuses everything else:
IllegalArgumentException: SimpleMessageConverter only supports String, byte[] and
Serializable payloads, received: com.ankurm.rabbit.OrderMessage
One bean fixes both directions, because the auto-configured template and the listener container factory both look for a MessageConverter:
@Bean
MessageConverter messageConverter() {
return new JacksonJsonMessageConverter();
}
Note the class name. Spring AMQP 4.1 ships Jackson2JsonMessageConverter (Jackson 2) and JacksonJsonMessageConverter (Jackson 3) side by side, exactly as Spring Kafka ships two serializer families. Boot 4 is a Jackson 3 application: pick the one without the 2.
One more API change to know about, because it fails at runtime rather than at compile time: RabbitAdmin.QUEUE_MESSAGE_COUNT now holds a Long. Every older example casts it to Integer.
Four exchange types
A producer never publishes to a queue. It publishes to an exchange with a routing key, and bindings decide where that lands. That indirection is the whole design, and it is why RabbitMQ can do things Kafka cannot — and why it loses things Kafka would not.
Direct is exact match: binding key new receives routing key new, nothing else. The workhorse, one queue per command type.
Fanout ignores the routing key entirely. Every bound queue gets a copy. Adding a queue adds a full copy of the traffic, which is the cost as well as the point.
Topic matches dot-separated words, and the wildcard rules are where intuition fails. Against a real broker, with bindings order.eu.* and order.#.high:
routing key orders.eu orders.high note
order.eu.high true true matches both
order.eu.low true false matches order.eu.* only
order.us.high false true matches order.#.high only
order.eu.west.high false true matches order.#.high only - * is one word
order.high false true matches order.#.high - # can be zero words
Two rows deserve a second look. order.eu.west.high does not match order.eu.*, because * matches exactly one word and west.high is two — regex intuition says otherwise. And order.highdoes match order.#.high, because # matches zero words, so a binding you wrote to mean “something in the middle” also matches “nothing in the middle”.
Design routing keys general-to-specific (order.eu.west.high, not high.order.eu.west). The wildcards work left to right, and a hierarchy you can bind usefully is one that starts broad.
Headers matches a map and ignores the routing key. x-match=all requires every named header present and equal; x-match=any requires one:
Message headers
x-match=all
x-match=any
priority=high, region=eu
yes
yes
priority=high
no
yes
priority=high, region=us
no
yes
The third row is the one to remember: all matches on value, not presence. A header that is there with the wrong value fails exactly like a missing one.
The default exchange. Publishing to the empty exchange name "" routes by queue name, using the routing key as the queue name, and every queue is implicitly bound to it. That is how convertAndSend("", "orders.work", message) works. It is the one piece of AMQP that behaves like a magic constant, and it is very convenient for tests and very brittle in production, because it bypasses the indirection that lets you add a consumer without touching the producer.
The message that goes nowhere and says nothing
Publish to a direct exchange with a routing key nothing is bound to. The broker discards the message. convertAndSend returns normally. Nothing is logged, anywhere, by anyone.
This is correct AMQP behaviour, and the symptom in production is “the consumer isn’t running” while the cause is three services away in a routing key somebody renamed.
Two settings are required, and they live in different places:
spring:
rabbitmq:
publisher-returns: true # on the connection factory
template:
mandatory: true # on the template
publisher-returns puts the connection into a mode where returns are listened for at all; mandatory asks the broker to return this publish when it is unroutable. Setting only mandatory — which is what most examples show — changes nothing. The test in the companion project was written that way first and failed.
Then register a callback:
Returns and confirms answer different questions, and you usually want both. A return says the broker had nowhere to route this. A confirm (publisher-confirm-type: correlated) says the broker took responsibility for it, or refused it. The trap inside the trap: an unroutable message gets a return followed by an ack — the broker successfully did nothing with it — so confirms alone will never tell you the message vanished.
Note too that the return callback fires asynchronously on the connection thread, long after convertAndSend returned successfully. It tells you a message was lost; it cannot stop the caller believing it succeeded.
The topology-level alternative is worth knowing: give an exchange an alternate-exchange argument and unroutable messages go there instead of being dropped, with no publisher-side configuration at all. It costs one exchange and one queue, and it catches the publishes made by services that forgot to set mandatory.
A dead-letter queue that actually works
There is no “send to DLQ” operation in AMQP. A dead-letter exchange is a queue argument, and messages arrive there as a side effect of three specific events.
deadLetterRoutingKey matters more than it looks. Without it the message is republished with its original routing key, so your DLQ binding has to anticipate every routing key the upstream might have used. Pinning it to a constant means one binding catches everything.
Event
x-death reason
basicNack / basicReject with requeue=false
rejected
message TTL expires (x-message-ttl or per-message)
expired
queue length or byte limit exceeded (x-max-length)
maxlen
And the one that is not a trigger: requeue=true.
Both of the first two, captured from real runs:
Same destination queue, different reason. That field is the whole diagnostic: rejected means a consumer looked at the work and refused it; expired means nobody got to it in time. Different incidents, different fixes, indistinguishable without reading the header.
The third trigger has a detail worth knowing. When x-max-length is exceeded RabbitMQ drops from the head, so the message dead-lettered is the oldest one already queued, not the one that just arrived:
=== x-death after x-max-length overflow ===
reason maxlen
queue orders.bounded
body {"orderId":"m-1","detail":"detail for m-1"}
Three messages into a queue that holds two, and m-1 is the one on the DLQ. A bounded queue under sustained overload therefore dead-letters your backlog while continuing to accept new work — usually what you want for telemetry, and exactly wrong for orders.
x-death is a list, not a map — one entry per queue the message has been dead-lettered from, with an accumulating count. That is how you build a retry limit: read x-death[0].count and stop republishing past a threshold.
redelivered 199 times
dead-lettered 0
still on queue 1
The dead-letter exchange is never consulted. x-death is never written. There is no counter to exhaust and no backoff. One poison message with an unconditional requeue=true handler saturates a consumer indefinitely — and because the queue depth stays at 1 the whole time, a depth alarm will not fire either. The only visible signal is CPU.
requeue=true is only correct when the failure is transient and you have a delay. With no delay, a message that failed because a database was down comes straight back while the database is still down. The redelivered flag is your one piece of state: channel.basicNack(tag, false, !envelope.isRedeliver()) is a one-attempt retry with no extra infrastructure. For anything more, use a delay queue — a queue with a TTL and a DLX pointing back at the work exchange turns “expire after 30 seconds” into “retry in 30 seconds”.
And do not point a DLQ’s own dead-letter exchange back at the queue that feeds it. A consistently failing message then cycles between the two forever, gaining an x-death entry each time until the header is the largest thing in the message.
Acknowledgement
Three modes, and the default is not the one you want for work that matters.
Mode
Behaviour
auto (default)
the container acks after the listener returns, nacks on exception
manual
your code calls basicAck / basicNack; nothing else does
none
the broker forgets the message the moment it is delivered
auto‘s name is misleading: it does not mean auto-ack in the AMQP sense — that is none — it means the container decides, based on whether your listener threw. none is genuinely fire-and-forget, with no redelivery and no flow control, which makes it right for metrics and wrong for everything else.
The two catch blocks are the point. A single catch (Exception) that nacks with requeue=true is the loop above; one that nacks with requeue=false dead-letters transient failures that would have succeeded on a retry. Deciding which kind of failure you had is work you cannot delegate to the container.
prefetch is the most effective knob nobody touches. It is how many un-acknowledged messages the broker will send one consumer. Spring Boot publishes no default, so AbstractMessageListenerContainer.DEFAULT_PREFETCH_COUNT applies — and that constant is 250. One slow consumer therefore parks 250 messages that no other consumer can take. Set it to 1 when messages are expensive and unevenly sized, to get round-robin distribution; leave it high for small uniform messages and throughput.
Related: a forgotten basicAck does not throw and does not lose the message. It counts against prefetch until the channel closes, so the consumer stops after 1 message or after 250, depending. Both look like “it just stopped”. rabbitmqctl list_queues name messages_ready messages_unacknowledged is what distinguishes them.
Changing your mind about a queue
Queue arguments are immutable. There is no ALTER QUEUE. Redeclaring an existing queue with different arguments is a channel-level error:
PRECONDITION_FAILED - inequivalent arg 'x-message-ttl' for queue 'orders.ttl' in vhost '/':
received '9999' but current is '1500', class-id=50, method-id=10
Reply code 406. The message is precise and helpful, and you will probably never see it, because of what Spring wraps it in:
AmqpIOException‘s own message is the literal string java.io.IOException. The next cause’s message is null. The useful text is two levels down — which is why this gets reported as “some IOException from RabbitMQ”, and why a log statement that walks the cause chain earns its keep.
Operationally this means changing a TTL, a max-length, a dead-letter exchange or a queue type on a live queue is a migration: declare the new queue under a new name, bind it alongside, move consumers, drain the old one, delete it. Plan the rename in from the start. orders.work.v2 is not ugly, it is honest.
Two related failure modes:
A failed declaration stops the ones behind it.RabbitAdmin declares beans on connection, and a PRECONDITION_FAILED closes the channel — declarations queued behind it on that channel do not happen. One stale queue argument can leave half your topology undeclared, and the symptom is a different queue being missing.
Declarations happen on connection, not on context refresh. A broken topology does not fail startup; it fails the first publish. If you want it to fail at boot, force a connection early with connectionFactory.createConnection() in an ApplicationRunner.
And one asymmetry worth knowing: missing-queues-fatal defaults to true for spring.rabbitmq.listener.simple and false for spring.rabbitmq.listener.direct. The same missing queue kills a simple container and is quietly tolerated by a direct one, which makes “but it works in that service” an unhelpful data point.
RabbitMQ or Kafka?
They differ in who owns the message, and everything else follows. RabbitMQ’s broker owns a message until it is acknowledged, and can therefore route it, expire it and dead-letter it on its own — which is why a working DLQ here is two queue arguments. Kafka’s broker remembers nothing about individual records; the consumer holds an offset, so the same outcome needs a retry topic and a publishing recoverer.
So: per-message routing, per-message retry, work queues with competing consumers, and a dead-letter path you get almost for free — RabbitMQ. A durable replayable log that many independent consumers read at their own pace, with strict per-key ordering and retention measured in weeks — Kafka. Choosing on throughput benchmarks is choosing on the least important axis.
Further reading
The companion project — nine tests against a real broker, and every transcript above
No Comments yet!