Skip to content

Messaging Basics Interview Questions & Answers

16 questions Updated 2026-06-26 Share:

Spring Boot messaging interview guide — why use a broker, JMS vs AMQP vs Kafka, JmsTemplate and @JmsListener, RabbitTemplate, KafkaTemplate and @KafkaListener, acknowledgement modes, idempotent consumers, DLQs, and at-least-once delivery.

Read the in-depth guideSpring Boot Messaging Basics: JMS, RabbitMQ, and Kafka Explained(opens in new tab)
16 of 16

A broker gives you asynchronous, decoupled, durable communication. The producer drops a message and moves on — it doesn't wait for the consumer, doesn't need it to be up right now, and doesn't even know who reads it. The broker buffers messages so a slow or down consumer causes back-pressure instead of failure, and it smooths spikes that would overwhelm a synchronous call.

REST call:   A waits for B; B must be up; tight coupling; spike = overload
Messaging:   A fires & forgets; B reads when ready; buffered; spike = queue grows

Rule of thumb: Choose a broker when you want temporal decoupling, buffering, and durability; choose REST when you need an immediate synchronous answer.

JMS is a Java API standard (an interface, not a wire protocol) implemented by brokers like ActiveMQ. AMQP is a wire protocol (language-agnostic) implemented by RabbitMQ — it has a rich routing model of exchanges and bindings. Kafka is a distributed, replayable commit log — messages are retained and read by offset, built for high-throughput streaming and event sourcing rather than classic queue semantics.

JMS    → Java API; ActiveMQ/Artemis; queues & topics
AMQP   → wire protocol; RabbitMQ; exchanges → bindings → queues
Kafka  → durable log; partitions & offsets; replayable streams

Rule of thumb: JMS = Java-standard queues, AMQP/RabbitMQ = flexible routing, Kafka = durable high-throughput streams you can replay.

A queue is point-to-point: each message is delivered to exactly one consumer (competing consumers load-balance the work). A topic is publish/subscribe: each message is delivered to every subscriber. Queues distribute work; topics broadcast events.

Queue (point-to-point):   msg → ONE of [C1, C2, C3]      (work sharing)
Topic (pub/sub):          msg → ALL of [S1, S2, S3]      (broadcast)

Kafka blends these: a partitioned topic where each consumer group gets every message but members within a group share partitions.

Rule of thumb: Queue = one consumer wins (distribute work); Topic = everyone gets a copy (broadcast).

Add the matching starter and Boot auto-configures the connection and template. spring-boot-starter- artemis (or ActiveMQ) for JMS, spring-boot-starter-amqp for RabbitMQ, and spring-kafka for Kafka. Each gives you a ready-to-inject template (JmsTemplate, RabbitTemplate, KafkaTemplate) plus listener-container infrastructure, configured from application.properties.

spring.rabbitmq.host=localhost
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.consumer.group-id=orders

Rule of thumb: Add the starter, set connection properties, and inject the auto-configured template — Boot wires the rest.

Send with JmsTemplate.convertAndSend(destination, payload) — a MessageConverter turns your object into a JMS message. Receive by annotating a method with @JmsListener(destination = "...") and adding @EnableJms; Spring's listener container pulls messages and invokes your method.

@Service
class Sender {
    private final JmsTemplate jms;
    void send(Order o) { jms.convertAndSend("orders", o); }
}

@Component
class Receiver {
    @JmsListener(destination = "orders")
    void receive(Order o) { process(o); }     // container delivers each message
}

Rule of thumb: JmsTemplate to send, @JmsListener to receive — same template/listener shape repeats across RabbitMQ and Kafka.

In AMQP a producer never publishes directly to a queue — it publishes to an exchange with a routing key. The exchange uses bindings to decide which queues get the message. Exchange types: direct (exact routing-key match), topic (wildcard patterns like order.*), fanout (every bound queue), and headers (match on header values).

// direct exchange: routing key "order.created" → bound queue
rabbitTemplate.convertAndSend("orders.exchange", "order.created", payload);

This indirection is what makes RabbitMQ's routing so flexible — you rewire delivery by changing bindings, not code.

Rule of thumb: RabbitMQ = producer → exchange → (binding + routing key) → queue; pick the exchange type to match your routing need.

Send with KafkaTemplate.send(topic, key, value) — the key decides the partition (same key → same partition → ordering). Consume with @KafkaListener(topics = "...", groupId = "..."). The group-id defines a consumer group; partitions are distributed across members of the group.

kafkaTemplate.send("orders", order.id(), order);     // key = id → ordered per id

@KafkaListener(topics = "orders", groupId = "billing")
void consume(Order o) { charge(o); }

Rule of thumb: In Kafka the message key drives partitioning and ordering, and the groupId drives how partitions are shared among consumers.

Kafka guarantees order only within a single partition, not across the topic. Messages with the same key land on the same partition, so they're processed in order relative to each other; messages with different keys may interleave across partitions. More partitions means more parallelism but less global ordering.

kafkaTemplate.send("orders", customerId, event);
// all events for one customerId stay ordered; different customers may interleave

Rule of thumb: Want ordering? Put the things that must stay ordered under the same key — Kafka orders per partition, not per topic.

An ack tells the broker a message was successfully processed so it can be removed/advanced. With auto-ack the broker considers a message done as soon as it's delivered — if your handler then crashes, the message is lost. With manual/client ack you acknowledge after successful processing, so a crash leaves the message to be redelivered. This is the core of delivery guarantees.

@JmsListener(destination = "orders")
void receive(Order o, Session session) throws JMSException {
    process(o);
    // manual ack: only now is the broker told it's done
}

Rule of thumb: Ack after processing (manual/client mode) for safety — auto-ack risks losing messages on crash.

At-least-once: the broker redelivers until acknowledged, so a message is never lost but may arrive more than once (e.g. handler succeeds, then crashes before acking). At-most-once: a message is delivered at most one time, so no duplicates but possible loss. Exactly-once is hard and usually approximated by at-least-once plus idempotent consumers.

at-least-once: no loss, possible duplicates   → make consumer idempotent
at-most-once:  possible loss, no duplicates    → only if loss is acceptable

Rule of thumb: Most systems run at-least-once + idempotency — you rarely get true exactly-once for free.

Because real brokers deliver at-least-once, the same message can arrive twice (redelivery after a crash, a rebalance, a retry). A non-idempotent consumer would double-charge or double-ship. Idempotency means processing the same message twice has the same effect as once — typically by tracking a unique message/business id and skipping ones you've already handled.

@KafkaListener(topics = "payments")
void charge(Payment p) {
    if (processedIds.putIfAbsent(p.id(), true) != null) return;  // already done
    chargeCard(p);
}

Rule of thumb: Assume duplicates will happen — dedupe on a stable id so reprocessing is a no-op.

A dead-letter queue (DLQ) is where messages go when they can't be processed — after exhausting retries, on a deserialization failure, or on explicit rejection. Instead of blocking the main queue or looping forever ("poison message"), the broker shunts the bad message aside so the rest keep flowing, and you inspect/replay the DLQ later.

main queue → handler fails N times → message routed to orders.DLQ
                                     (main queue keeps moving)

Rule of thumb: Configure a DLQ + retry limit so one poison message can't stall the queue or spin forever.

A MessageConverter serializes your Java object into the broker's message format on send and deserializes it on receive. The common choice is a JSON converter (e.g. MappingJackson2MessageConverter / JsonMessageConverter) so payloads are language-neutral. Without configuring one, JMS/AMQP default to Java serialization, which is brittle and not interoperable.

@Bean
MessageConverter jsonConverter() {
    return new MappingJackson2MessageConverter();   // objects ↔ JSON
}

Rule of thumb: Register a JSON MessageConverter so payloads are interoperable and not tied to Java serialization.

The dual-write problem: you must update the database AND publish a message, but they're two separate systems with no shared transaction. If the DB commits and the publish fails (or vice-versa), state is inconsistent. The standard fix is the transactional outbox: write the message into an outbox table in the same DB transaction as your data, then a separate relay publishes outbox rows to the broker.

tx { save(order); insert into outbox(event) }   ← atomic
relay: read outbox → publish to broker → mark sent

Rule of thumb: Don't try to make a DB commit and a broker publish atomic — use the outbox pattern to make the write the single source of truth.

Run more consumer threads/instances. In Spring you set concurrency on the listener container (e.g. @KafkaListener(concurrency = "3") or the JMS/Rabbit container's concurrency), and you scale out by running more app instances. The ceiling differs: with Kafka, parallelism is capped by the number of partitions (one consumer per partition per group); with queues, competing consumers just share the load.

@KafkaListener(topics = "orders", groupId = "billing", concurrency = "3")
void consume(Order o) { ... }   // up to 3 threads, capped by partition count

Rule of thumb: Scale consumers with concurrency + more instances — but remember Kafka throughput is bounded by partition count.

A poison message is one that always fails — bad data, a bug, a missing dependency — so it gets redelivered endlessly, blocking or looping the consumer. The cure is a bounded retry policy: cap attempts (often with backoff), and after the limit route it to a DLQ and move on. Never retry forever.

// Spring Kafka: retry a few times, then send to <topic>.DLT
@RetryableTopic(attempts = "3", dltStrategy = DltStrategy.FAIL_ON_ERROR)
@KafkaListener(topics = "orders")
void consume(Order o) { process(o); }

Rule of thumb: Bound retries and divert failures to a DLQ so one bad message can't wedge the whole consumer.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel