Why a broker instead of a REST call
A direct REST call between two services is synchronous and tightly coupled: service A waits for service B, B has to be up right now, and a traffic spike on A becomes an overload on B. A message broker breaks all three constraints. The producer drops a message and moves on; the broker buffers it; the consumer reads it whenever it's ready. You get temporal decoupling (B can be down), buffering (spikes become a growing queue, not a crash), and durability (messages survive a consumer restart). The trade-off is you give up the immediate synchronous answer — so use REST when you need a reply, and messaging when you can fire and forget.
The three families: JMS, AMQP, Kafka
These three names get thrown around interchangeably, but they're different kinds of things:
JMS → a Java API standard (an interface); implemented by ActiveMQ/Artemis
AMQP → a wire protocol (language-agnostic); implemented by RabbitMQ
Kafka → a distributed, replayable commit log; partitions and offsets
JMS is just a Java API — code against the interface, plug in a broker. AMQP/RabbitMQ is a wire protocol with a rich routing model. Kafka isn't really a queue at all; it's a durable log you read by offset, built for high-throughput streaming and replay. Choosing between them is mostly about routing flexibility (RabbitMQ) versus throughput and replayability (Kafka) versus Java-standard simplicity (JMS).
Underneath them all sits the queue-vs-topic distinction. A queue is point-to-point — each message goes to exactly one consumer, so competing consumers share work. A topic is publish/subscribe — each message goes to every subscriber, so it broadcasts. Kafka blends them with consumer groups: every group gets every message, but members within a group split the partitions.
Send and receive: the same shape everywhere
Spring Boot's starters auto-configure a connection and a template per technology. Add the starter, set connection properties, inject the template. The send/receive shape repeats across all three:
spring.rabbitmq.host=localhost
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.consumer.group-id=orders
// JMS
jmsTemplate.convertAndSend("orders", order);
@JmsListener(destination = "orders")
void receive(Order o) { process(o); }
// Kafka
kafkaTemplate.send("orders", order.id(), order); // key drives partition + ordering
@KafkaListener(topics = "orders", groupId = "billing")
void consume(Order o) { charge(o); }
RabbitMQ adds one twist worth knowing: a producer never publishes directly to a queue. It publishes to an
exchange with a routing key, and bindings decide which queues receive it. Exchange types — direct, topic
(wildcards like order.*), fanout, and headers — give you flexible routing you can rewire without touching code.
Kafka's twist is partitioning. The message key decides the partition, and Kafka guarantees order only within a partition. Put everything that must stay ordered under the same key (say, a customer id) and those events stay in sequence; different keys may interleave. More partitions buys parallelism at the cost of global ordering.
Delivery guarantees: the part that actually matters
The hardest part of messaging isn't sending — it's making sure messages aren't lost or mishandled. It starts with acknowledgements. An ack tells the broker a message was successfully processed so it can be removed. With auto-ack, the broker marks a message done the moment it's delivered — if your handler then crashes, the message is gone. With manual ack, you acknowledge only after successful processing, so a crash leaves the message to be redelivered. That choice determines your delivery semantics:
at-least-once: redeliver until acked → never lost, but may arrive twice
at-most-once: deliver at most once → no duplicates, but may be lost
Almost every real system runs at-least-once, which means duplicates will happen — a redelivery after a crash, a consumer-group rebalance, a retry. So consumers must be idempotent: processing the same message twice has the same effect as once. The usual trick is to track a stable business id and skip what you've already handled:
@KafkaListener(topics = "payments")
void charge(Payment p) {
if (processedIds.putIfAbsent(p.id(), true) != null) return; // already done
chargeCard(p);
}
"Exactly-once" is mostly a marketing phrase — in practice it's at-least-once plus idempotency.
Poison messages and dead-letter queues
Some message will always fail — bad data, a bug, a missing dependency. Left alone, it gets redelivered forever, blocking or looping the consumer. That's a poison message, and the cure is a bounded retry policy that, after exhausting attempts, shunts the message to a dead-letter queue so the rest of the traffic keeps flowing:
@RetryableTopic(attempts = "3", dltStrategy = DltStrategy.FAIL_ON_ERROR)
@KafkaListener(topics = "orders")
void consume(Order o) { process(o); } // 3 tries, then routed to orders.DLT
You inspect and replay the DLQ later. Never retry forever.
The dual-write problem
One last trap that comes up constantly: you need to update the database and publish a message, but they're two
systems with no shared transaction. If the DB commits and the publish fails (or the reverse), your state is
inconsistent. Trying to wrap both in one transaction doesn't work. The standard fix is the transactional
outbox: write the message into an outbox table inside the same DB transaction as your data, then a separate
relay reads outbox rows and publishes them:
tx { save(order); insert into outbox(event) } ← atomic, one transaction
relay: read outbox → publish to broker → mark sent
The database becomes the single source of truth, and publishing becomes a reliable follow-up rather than a risky second write.
Rule of thumb: Reach for a broker when you want decoupling and durability; pick JMS, RabbitMQ, or Kafka by routing and throughput needs; and design every consumer around at-least-once delivery — idempotent, with a DLQ, and an outbox when a DB write and a publish must stay in sync.