Messaging Fundamentals
Before Kafka, RabbitMQ or SQS, there are a handful of ideas every message-driven system shares: why you would send a message at all, what a delivery guarantee really promises, why duplicates are normal, how ordering and parallelism fight, and how a failure path should be shaped. Learn these once and each broker becomes a set of settings. The page ends by building an at-least-once queue from scratch in plain Java.
Coupling in Time
Every service call has a hidden contract: the callee must be up, reachable and fast at the moment of the call. When a request fans out to several services, those requirements multiply. The user waits for the slowest dependency, and the request fails if any one of them fails. This is temporal coupling, and it is the problem a message broker removes: the producer hands a message to a durable intermediary and moves on; consumers pick it up whenever they are able to.
A broker buys four distinct things, and it is worth naming them because each one is a reason you may or may not need messaging in a given design:
Temporal decoupling
Producer and consumer need not run at the same time. A consumer can be down for a deploy and catch up afterwards.
Load levelling
A traffic spike fills the queue instead of overwhelming the consumer. Work is done at a sustainable rate.
Fan-out
One event, many independent consumers. Adding a new consumer needs no change to the producer.
- Latency is the sum of four network calls, and availability is the product of four availabilities.
- Email and analytics are not needed to answer the user, yet an SMTP outage now fails checkouts.
- If
inventory.reservefails afterpayments.chargesucceeded, the system is in an inconsistent state with no record of what to repair. - A new requirement (fraud scoring, loyalty points) means editing and redeploying
CheckoutService.
This version has its own trap: the order is saved in the database and the event is sent to the broker, and those are two different systems with no shared transaction. If the process dies between them you have an order without an event, or an event without an order. Page 07 (Event-Driven Patterns) solves this with the transactional outbox. Messaging does not remove complexity; it moves it to places where you can handle it deliberately.
Producers, Brokers, Consumers
Every system in this section, Kafka, RabbitMQ, SQS, SNS, EventBridge, Kinesis, fits the same skeleton: producers write messages, a broker stores and routes them, consumers read them. The products differ in how routing works, how long messages are kept, and who tracks which messages a consumer has seen. The words also differ, which makes first contact with a new broker confusing, so here is a translation table.
| Idea | Kafka | RabbitMQ | Amazon SQS | Amazon SNS |
|---|---|---|---|---|
| A unit of data | Record | Message | Message | Message |
| Where producers write | Topic (partition) | Exchange | Queue | Topic |
| Where messages wait | Partition log | Queue | Queue | Nowhere (push to subscribers) |
| Routing | Key hash to partition | Binding key / headers | None (one queue) | Subscription filter policy |
| Who tracks progress | Consumer group offset | Broker (acks) | Broker (visibility timeout, delete) | Broker (delivery retries) |
| After reading | Message stays until retention | Removed on ack | Removed on delete | n/a |
| Acknowledge by | Commit offset | basic.ack | DeleteMessage | HTTP 2xx / SQS accepted |
Whatever the broker, a message is a payload plus metadata. Knowing which parts the broker understands and which are yours to define prevents a lot of design mistakes.
| Part | Who reads it | Purpose | Example |
|---|---|---|---|
| Body / value | Only your code | The business data. The broker treats it as opaque bytes. | {"orderId":"o-42","total":99.5} |
| Key | Broker (routing/partitioning) | Decides placement and therefore ordering. Same key, same partition (Kafka) or same group (SQS FIFO). | customerId=c-7 |
| Headers / attributes | Broker and consumers | Metadata that is not business data: trace id, content type, schema version, event type. | traceparent, event-type=OrderPlaced |
| Message id | Broker (SQS, RabbitMQ) or you | Unique identity, the basis of deduplication. | UUID |
| Timestamp | Broker or producer | Ordering by time, retention, lag in seconds. | create time or log-append time |
- If the only record of "this is an
OrderPlacedv2" is a broker header, it is lost when the message is copied to a file, a data lake or another broker. Put the type and version inside the payload envelope (next chapter) and treat headers as transport hints.
Commands, Events and Documents
"Message" covers three different intents, and mixing them up is the most common design error. The intent decides the name, the number of consumers and the coupling between teams.
| Command | Event | Document / reply | |
|---|---|---|---|
| Meaning | Please do this | This has happened | Here is some data |
| Naming | Imperative: ReserveStock | Past tense: OrderPlaced | Noun: CustomerSnapshot |
| Consumers | Exactly one owner | Zero to many, unknown to the producer | Usually the requester |
| Producer knows about consumer? | Yes, it addressed it | No | Yes (reply address) |
| Can the receiver refuse? | Yes, and should report back | No, it is a fact | n/a |
| Typical transport | Queue (point-to-point) | Topic / log (publish-subscribe) | Reply queue / topic |
An event can carry just an identifier (notification) or the full state (event-carried state transfer). Both are valid; they trade coupling for chattiness.
| Thin (notification) | Fat (state transfer) | |
|---|---|---|
| Payload | { "orderId": "o-42" } | { "orderId": "o-42", "customerId": ..., "lines": [...], "total": ... } |
| Consumer needs | A call back to the producer to fetch details | Nothing more; it can act on the message alone |
| Coupling | Runtime coupling: producer API must be up and consistent | Schema coupling: payload shape must be managed |
| Risk | Fetched data may be newer than the event (race) | Payload may be large or leak data to consumers who should not see it |
| Choose when | Data is large, sensitive or changes rapidly | Consumers are many and independent, or you need replay |
- Adding an optional field is compatible: old consumers ignore it, new consumers handle its absence.
- Removing or renaming a field, or changing its type or meaning, is a breaking change: publish a new
typeor bumpschemaVersionand run both until every consumer has moved. - Configure your JSON mapper to ignore unknown properties (
FAIL_ON_UNKNOWN_PROPERTIES=falsein Jackson) so additive changes do not crash old consumers. - For high volume or many teams use a schema registry with Avro or Protobuf and enforce compatibility rules in CI (pages 03 and 04 cover the Kafka side).
At-Most-Once, At-Least-Once, Exactly-Once
A delivery guarantee answers one question: if something goes wrong, can a message be lost, duplicated, or neither? The network cannot tell the difference between "the receiver is slow", "the receiver is dead" and "the receiver finished but its reply got lost", so a sender that wants certainty has to choose which mistake it prefers.
| Guarantee | How it works | On failure | Cost | Use for |
|---|---|---|---|---|
| At-most-once | Acknowledge before processing, or never retry | Message may be lost; never duplicated | Cheapest | Metrics, telemetry, anything where loss is acceptable |
| At-least-once | Acknowledge after processing; retry until acknowledged | Message is never lost; may be processed more than once | Consumers must handle duplicates | The default for almost everything |
| Exactly-once | Not achievable end to end over an unreliable network. Achieved as at-least-once delivery + idempotent processing | Effect happens once | Dedup state or transactions | Payments, inventory, anything with side effects |
"Exactly-once" deserves care, because vendors use the phrase for narrower things. Kafka offers idempotent producers and transactions that make read-process-write cycles inside Kafka atomic (page 03). SQS FIFO deduplicates identical sends within a 5-minute window. Both are real and useful, but neither extends into your database or an external HTTP call. The moment a consumer writes to a database, sends an email or charges a card, the guarantee is yours to build.
Reliability here means no loss, and no loss is exactly what makes duplicates unavoidable. A system that never loses a message must resend when unsure. Treat every consumer as one that will see the same message twice, because at some point in production it will.
Making Duplicates Harmless
A handler is idempotent when running it twice has the same effect as running it once. You can get there in three ways, and the best designs use the first whenever possible.
| Technique | Idea | Example | Watch out for |
|---|---|---|---|
| Natural idempotency | Write absolute state, not deltas | SET status = 'PAID' instead of balance = balance - 10; upsert by business key | Out-of-order delivery: an older message can overwrite a newer one. Add a version check |
| Deduplication table | Record each processed message id; skip if seen | Insert into processed_message in the same transaction as the work | Cleanup horizon must be longer than the maximum redelivery window |
| Conditional update | Apply only if the current state matches what the message expects | UPDATE ... WHERE id = ? AND version = ? | A rejected update is not an error: acknowledge and drop it |
The key rule: the dedup record and the business effect must commit in the same database transaction. If they are separate, a crash between them either loses the work or repeats it.
- The tempting version is
try { insert } catch (DuplicateKeyException e) { return; }. On PostgreSQL a failed statement marks the whole transaction as aborted ("current transaction is aborted, commands ignored until end of transaction block"), so any later statement fails. UseINSERT ... ON CONFLICT DO NOTHINGand check the update count. - On Oracle (
MERGE, or catchingDuplicateKeyExceptionafter a unique violation) and MySQL the transaction survives the error, but the count-based approach is portable and avoids using exceptions for control flow.
- Use an id generated by the producer when the business fact is created (the
EventEnvelope.idabove), not the broker's per-delivery id. A broker-assigned id changes if the producer retried and the broker stored two copies. - For commands, a natural key works too:
orderId + ":reserve". - Keep dedup rows at least as long as the maximum time a message can be redelivered (retention period plus retry delays). A scheduled
DELETE ... WHERE processed_at < ?is enough.
Order Costs Parallelism
Strict global ordering means one lane and one consumer: the next message cannot start until the previous one finished. That is correct and slow. Every real system therefore guarantees order only within a key, and parallelises across keys.
| Broker | The lane is called | Chosen by | Order guaranteed for |
|---|---|---|---|
| Kafka | Partition | Hash of the record key | Records of one partition, read by one consumer of the group |
| SQS FIFO | Message group | MessageGroupId | Messages of one group; different groups are processed in parallel |
| SQS Standard | None | - | Best-effort only, no guarantee |
| RabbitMQ | Queue | Routing | Order within a queue with a single consumer; broken by multiple consumers and by requeues |
| SNS / EventBridge | None (Standard) | - | No order guarantee. SNS FIFO topics add group ordering |
- Decide what must be ordered, usually "events of the same entity", make that entity id the key, and accept no order elsewhere.
- Make handlers tolerant instead of relying on perfect order: carry a version or timestamp in each event and ignore events older than the stored state.
Backpressure and Lag
A broker decouples speeds, it does not remove the speed limit. If producers send faster than consumers process, the queue grows until it hits a limit: disk, retention, memory, or the patience of the business. The unprocessed backlog is called lag (Kafka) or queue depth (RabbitMQ, SQS).
| Model | Who controls the rate | Used by | Protection against overload |
|---|---|---|---|
| Pull | The consumer asks for more when ready | Kafka (poll), SQS (ReceiveMessage) | Built in: a slow consumer simply polls less often |
| Push | The broker sends as fast as it can | RabbitMQ basic.consume, SNS HTTP | Needs a limit: RabbitMQ prefetch (basic.qos) caps unacknowledged messages per consumer |
Lag changes at the difference between the arrival rate and the total processing rate. Doing this sum before production prevents most capacity incidents.
| Quantity | Value | How |
|---|---|---|
| Arrival rate | 5,000 messages/s | Measured peak |
| One consumer | 900 messages/s | Measured: 1.1 ms per message with DB write |
| Four consumers | 3,600 messages/s | 4 x 900 |
| Lag growth with 4 | +1,400 messages/s = 84,000 per minute | 5,000 - 3,600 |
| Consumers needed at peak | 6 minimum (5,000 / 900 = 5.56) | Plan for 8 to absorb bursts and catch up |
| Partitions needed (Kafka) | At least 8 | A consumer group cannot use more consumers than partitions |
Little's law gives the second half of the picture: the average number of messages in the system equals arrival rate times time in system (L = λ × W). If you want end-to-end latency under 2 seconds at 5,000 messages/s, the average backlog must stay under 10,000 messages. That is a number you can alert on.
- If each message writes to a database that tops out at 2,000 inserts/s, adding consumers only increases contention. Find the real constraint first: Performance: Database Performance covers the usual limits.
- In Kafka, more consumers than partitions sit idle. Partition count is the ceiling on parallelism.
Retries, Poison Messages and the Dead-Letter Queue
Not every failure is the same, and the right reaction depends on the kind. Transient failures (timeout, deadlock, a downstream restart) succeed if tried again. Permanent failures (malformed JSON, a business rule violation, a bug) never will. A message that always fails is a poison message.
- In an ordered lane (a Kafka partition, a SQS FIFO group) a message that is retried forever blocks every message behind it. This is head-of-line blocking. Symptom: lag on one partition grows while others are idle, and the same stack trace repeats in the log.
- The cure is to bound the retries and move the message aside (a dead-letter queue or retry topic) so the lane keeps flowing.
Retrying immediately hammers a service that is already struggling, and many consumers retrying in lockstep produce a synchronised wave. Exponential backoff spaces attempts out; jitter randomises them so they spread.
| Question | Guidance |
|---|---|
| How many attempts? | Small and finite: 3 to 5 for in-process retries. Then move the message aside. |
| Retry in place or via a retry queue? | In place blocks the lane and holds the message. Delays longer than a few seconds belong in a retry topic/queue with a delay, so the main lane keeps moving. |
| Which errors to retry? | Only transient ones. Send deserialization and validation failures to the DLQ immediately. |
| What goes in the DLQ? | The original message plus failure metadata: exception class, message, attempt count, source topic/partition/offset, timestamp. |
| Who owns the DLQ? | Someone, with an alert on depth greater than zero. A DLQ nobody watches is a silent data-loss mechanism. |
Each broker implements these ideas differently: Kafka has no built-in DLQ (you publish to a dead-letter topic yourself or let Spring Kafka do it), RabbitMQ has dead-letter exchanges and quorum-queue delivery limits, and SQS has a redrive policy with maxReceiveCount. Pages 04, 05, 06 and 08 cover the concrete mechanisms.
An At-Least-Once Queue in Plain Java
Building the mechanism once makes every broker's documentation easier to read. This 70-line queue has the three behaviours that define at-least-once delivery: receiving hides a message instead of deleting it, an ack deletes it, and a message that is not acked becomes visible again after a visibility timeout. A receive counter moves poison messages to a dead-letter list. It is the model behind SQS and, with different bookkeeping, RabbitMQ.
- Receive does not remove. The message stays in
entrieswith a futurevisibleAt. That single field is the visibility timeout. - Ack is the only delete. A consumer that crashes (never calls
ack) causes redelivery automatically: no error handling is needed in the broker. - Duplicates are inevitable. If consumer A is merely slow and not dead, its message reappears after 30 seconds and consumer B processes it too. Both then ack; the second ack returns
false. Idempotent handlers (chapter 05) are the only defence. - The timeout is a trade-off. Too short: slow-but-healthy consumers cause duplicates. Too long: a crashed consumer's message sits invisible for ages. SQS lets a consumer extend it (
ChangeMessageVisibility) for exactly this reason. - **
maxReceivesis the redrive policy.** Without it, a poison message cycles forever.
- Change
receive()so that it does not hide the message, only returns it. Now two consumers receive the same message at once. That is what a queue without visibility timeouts looks like, and why the hiding step exists.
Questions You Should Be Able to Answer
Crisp answers for the questions that come up in interviews on this topic.
- Q: Why use a message broker instead of a direct REST call?
To remove temporal coupling (the consumer need not be up), level load spikes, fan out one event to many consumers and isolate failures. The cost is eventual consistency, harder debugging and the need to handle duplicates and ordering. Use a direct call when the caller needs the answer now. - Q: Explain at-most-once, at-least-once and exactly-once.
At-most-once acks before processing: may lose, never duplicates. At-least-once acks after processing: never loses, may duplicate. Exactly-once is not achievable over an unreliable network end to end; in practice it means at-least-once delivery plus an idempotent consumer (or a transaction that covers both the effect and the offset). - Q: Why can a message be delivered twice even with a perfect broker?
The ack can be lost, the consumer can crash after processing but before acking, processing can exceed the visibility or session timeout, a rebalance can replay uncommitted work, and a producer can retry after a lost response. - Q: How do you make a consumer idempotent?
Prefer naturally idempotent writes (set absolute state, upsert by business key). Otherwise record the message id in a dedup table in the same transaction as the effect, or use a conditional update with a version check. - Q: Why must the dedup record share a transaction with the business write?
If they commit separately, a crash between them either skips work that was never done (record first) or repeats work that was done (work first). - Q: How do you preserve ordering at scale?
Order within a key, not globally: partition by entity id (Kafka partition key, SQS FIFO message group). One consumer per lane, no parallel processing inside a lane, and make handlers tolerant of stale events with versions. - Q: What is a poison message and how do you handle it?
A message that can never be processed successfully. Bound retries, then move it to a dead-letter queue with failure metadata, alert on the DLQ, and provide a way to fix and redrive. Never retry indefinitely in an ordered lane, because it blocks everything behind it. - Q: Command vs event?
A command is an instruction to one owner, named in the imperative; the sender expects it done. An event is a past-tense fact published to whoever cares; the sender does not know the consumers and cannot demand action. - Q: A queue is growing. What do you check?
Compare arrival and processing rates; check consumer count against partition count (Kafka); check consumer errors and retries; check the downstream dependency (database, API); check for a poison message blocking a lane; only then scale. - Q: What is the dual-write problem?
Updating a database and publishing a message are two systems with no shared transaction, so a crash between them leaves them inconsistent. The usual solution is the transactional outbox pattern (page 07).
Cheat Sheet and Recap
| Concern | Rule | Remember |
|---|---|---|
| Why messaging | Decouple in time, level load, fan out | It trades simplicity for resilience |
| What to send | Commands to one owner; events as facts | Always wrap in an envelope: id, type, version, time |
| Delivery | Default to at-least-once | Ack after the effect is durable |
| Duplicates | Assume every message arrives twice | Dedup row + effect in one transaction |
| Ordering | Order per key, parallel across keys | Retries and extra consumers reorder |
| Overload | Measure lag = arrival - processing | Partitions cap Kafka parallelism |
| Failures | Backoff + jitter, bounded attempts, DLQ | Alert on DLQ depth |
| Dual write | Never DB write + publish naively | Use the outbox (page 07) |
- A broker decouples in time, not in speed. A queue that grows without bound is an outage that has not happened yet.
- At-least-once is the practical default, which means duplicates are normal and idempotent consumers are mandatory.
- Exactly-once = at-least-once + idempotence. Vendor "exactly-once" features stop at the edge of the broker.
- The dedup record and the business effect share one transaction.
- Order exists per key only. Choose the key to match the entity whose order matters.
- Retry transient errors with backoff and jitter; move permanent failures to a DLQ and watch the DLQ.
- Do the capacity arithmetic first: arrival rate, per-consumer rate, consumers, partitions.
- 02 Kafka Architecture - the log, partitions, replication and why Kafka behaves differently from a queue.
- Spring: Caching & Resilience - Resilience4j retry and circuit breaker, which wrap the same calls consumers make.
- Spring: Transactions - the transaction boundaries the dedup table and the outbox rely on.