Messaging · 01

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.

11
Chapters
3
Guarantees
5
Duplicate sources
1
Plain-Java build
01
Chapter One - Why It Exists

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.

The same checkout, synchronous vs message-driven
Synchronous: the checkout request waits for every dependency Checkout Payment Inventory Email Analytics All four must be up, and fast, for the user to get a response. 0.999 x 0.999 x 0.999 x 0.999 = 0.996 (99.9% each becomes 99.6% overall) Message-driven: the checkout request only needs the broker Checkout OrderPlaced Broker Payment Inventory Email + Analytics Each consumer works at its own pace; a slow or dead one does not block the checkout.

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.

Before: a synchronous call chain
Java - everything inside the request thread
@Service public class CheckoutService { private final PaymentClient payments; private final InventoryClient inventory; private final EmailClient email; private final AnalyticsClient analytics; public Order checkout(Cart cart) { Order order = orders.save(Order.from(cart)); payments.charge(order); // 300 ms, sometimes 30 s inventory.reserve(order); // 80 ms email.sendConfirmation(order); // 500 ms, third-party SMTP analytics.track(order); // nobody is waiting for this return order; } }
⚠ What is wrong with this
  • 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.reserve fails after payments.charge succeeded, 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.
After: publish a fact and let consumers react
Java - one publish, consumers are somebody else's problem
@Service public class CheckoutService { private final OrderRepository orders; private final OrderEvents events; // thin wrapper over the broker client @Transactional public Order checkout(Cart cart) { Order order = orders.save(Order.from(cart)); events.publish(new OrderPlaced(order.id(), order.customerId(), order.total())); return order; // payment, stock, email react independently } }

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.

Key insight: A synchronous call is a phone call: both people must be on the line at once. A message is a letter dropped in a postbox: the sender walks away, the postbox keeps it safe, and the recipient reads it when they can. Letters can arrive late, twice, or out of order, and everything else in this section is about coping with that.
02
Chapter Two - Vocabulary

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.

The moving parts of any messaging system
Producer your service send / publish BROKER Topic / Exchange routing entry point Storage disk, replicated Queue / Partition ordered buffer Offsets / Acks delivery state deliver / poll Consumer A Consumer B Consumer C Producer and consumer never talk to each other. Everything they share is the message format and the broker.
The same ideas under different names
IdeaKafkaRabbitMQAmazon SQSAmazon SNS
A unit of dataRecordMessageMessageMessage
Where producers writeTopic (partition)ExchangeQueueTopic
Where messages waitPartition logQueueQueueNowhere (push to subscribers)
RoutingKey hash to partitionBinding key / headersNone (one queue)Subscription filter policy
Who tracks progressConsumer group offsetBroker (acks)Broker (visibility timeout, delete)Broker (delivery retries)
After readingMessage stays until retentionRemoved on ackRemoved on deleten/a
Acknowledge byCommit offsetbasic.ackDeleteMessageHTTP 2xx / SQS accepted
Anatomy of one message

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.

PartWho reads itPurposeExample
Body / valueOnly your codeThe business data. The broker treats it as opaque bytes.{"orderId":"o-42","total":99.5}
KeyBroker (routing/partitioning)Decides placement and therefore ordering. Same key, same partition (Kafka) or same group (SQS FIFO).customerId=c-7
Headers / attributesBroker and consumersMetadata that is not business data: trace id, content type, schema version, event type.traceparent, event-type=OrderPlaced
Message idBroker (SQS, RabbitMQ) or youUnique identity, the basis of deduplication.UUID
TimestampBroker or producerOrdering by time, retention, lag in seconds.create time or log-append time
⚠ Do not put meaning in the broker metadata only
  • If the only record of "this is an OrderPlaced v2" 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.
03
Chapter Three - What You Send

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.

CommandEventDocument / reply
MeaningPlease do thisThis has happenedHere is some data
NamingImperative: ReserveStockPast tense: OrderPlacedNoun: CustomerSnapshot
ConsumersExactly one ownerZero to many, unknown to the producerUsually the requester
Producer knows about consumer?Yes, it addressed itNoYes (reply address)
Can the receiver refuse?Yes, and should report backNo, it is a factn/a
Typical transportQueue (point-to-point)Topic / log (publish-subscribe)Reply queue / topic
Thin events vs fat events

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 needsA call back to the producer to fetch detailsNothing more; it can act on the message alone
CouplingRuntime coupling: producer API must be up and consistentSchema coupling: payload shape must be managed
RiskFetched data may be newer than the event (race)Payload may be large or leak data to consumers who should not see it
Choose whenData is large, sensitive or changes rapidlyConsumers are many and independent, or you need replay
Always send an envelope
Java - the same envelope for every event
public record EventEnvelope<T>( String id, // unique per event, the idempotency key for consumers String type, // "OrderPlaced" int schemaVersion, // 1, 2, ... changes only for incompatible changes Instant occurredAt, // when the fact happened, not when it was sent String correlationId, // follows one business flow across services T payload) { public static <T> EventEnvelope<T> of(String type, int version, String correlationId, T payload) { return new EventEnvelope<>(UUID.randomUUID().toString(), type, version, Instant.now(), correlationId, payload); } } public record OrderPlaced(String orderId, String customerId, BigDecimal total) {}
Schema evolution rules that save you later
  • 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 type or bump schemaVersion and run both until every consumer has moved.
  • Configure your JSON mapper to ignore unknown properties (FAIL_ON_UNKNOWN_PROPERTIES=false in 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).
04
Chapter Four - Delivery Guarantees

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.

How a duplicate is born: the acknowledgment is lost, not the work
Producer Broker Consumer 1 send(msg) 2 ack lost in network 3 retry send(msg) broker now holds the message twice 4 deliver consumer does the work 5 ack lost / consumer dies 6 redeliver (timeout) work is done a second time
GuaranteeHow it worksOn failureCostUse for
At-most-onceAcknowledge before processing, or never retryMessage may be lost; never duplicatedCheapestMetrics, telemetry, anything where loss is acceptable
At-least-onceAcknowledge after processing; retry until acknowledgedMessage is never lost; may be processed more than onceConsumers must handle duplicatesThe default for almost everything
Exactly-onceNot achievable end to end over an unreliable network. Achieved as at-least-once delivery + idempotent processingEffect happens onceDedup state or transactionsPayments, 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.

Where duplicates come from
Producer sends, response is lost, producer retries
▶
The broker stored the first copy. Retry creates a second one. Fix: idempotent producer (Kafka) or deduplication id (SQS FIFO)
Consumer processes, then crashes before the ack
▶
The broker assumes it was never processed and redelivers. This is the normal at-least-once cost
Processing takes longer than the visibility or session timeout
▶
The broker decides the consumer is dead and gives the message to another one while the first is still working
Consumer-group rebalance
▶
Partitions move to another consumer, which resumes from the last committed offset, replaying anything not yet committed
Broker failover
▶
A new leader may not have seen the latest acks; some delivery state is replayed
⚠ "Our broker is reliable, so we do not need idempotency"

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.

05
Chapter Five - Idempotent Consumers

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.

TechniqueIdeaExampleWatch out for
Natural idempotencyWrite absolute state, not deltasSET status = 'PAID' instead of balance = balance - 10; upsert by business keyOut-of-order delivery: an older message can overwrite a newer one. Add a version check
Deduplication tableRecord each processed message id; skip if seenInsert into processed_message in the same transaction as the workCleanup horizon must be longer than the maximum redelivery window
Conditional updateApply only if the current state matches what the message expectsUPDATE ... WHERE id = ? AND version = ?A rejected update is not an error: acknowledge and drop it
The deduplication table, step by step

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.

SQL - one row per processed message
CREATE TABLE processed_message ( message_id VARCHAR(100) PRIMARY KEY, consumer VARCHAR(100) NOT NULL, processed_at TIMESTAMP NOT NULL DEFAULT now() );
Java - claim the message id, then do the work, in one transaction
@Service public class InventoryHandler { private final JdbcTemplate jdbc; private final StockService stock; @Transactional public void handle(EventEnvelope<OrderPlaced> event) { int claimed = jdbc.update( "INSERT INTO processed_message (message_id, consumer) VALUES (?, ?) " + "ON CONFLICT (message_id) DO NOTHING", event.id(), "inventory"); if (claimed == 0) { return; // duplicate: already processed, just ack it } stock.reserve(event.payload().orderId()); // same transaction as the claim } }
⚠ Catching the duplicate-key exception inside the transaction
  • 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. Use INSERT ... ON CONFLICT DO NOTHING and check the update count.
  • On Oracle (MERGE, or catching DuplicateKeyException after a unique violation) and MySQL the transaction survives the error, but the count-based approach is portable and avoids using exceptions for control flow.
Choosing the message id
  • Use an id generated by the producer when the business fact is created (the EventEnvelope.id above), 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.
06
Chapter Six - Ordering

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.

Order is guaranteed only inside one lane
Producer sends: A1, B1, A2, C1, B2, A3 (letter = key, number = sequence) Producer lane 0: A1 A2 A3 Consumer X lane 1: B1 B2 Consumer Y lane 2: C1 Consumer Z Within a lane the order is kept. Across lanes there is no order: B1 may be processed before A1.
BrokerThe lane is calledChosen byOrder guaranteed for
KafkaPartitionHash of the record keyRecords of one partition, read by one consumer of the group
SQS FIFOMessage groupMessageGroupIdMessages of one group; different groups are processed in parallel
SQS StandardNone-Best-effort only, no guarantee
RabbitMQQueueRoutingOrder within a queue with a single consumer; broken by multiple consumers and by requeues
SNS / EventBridgeNone (Standard)-No order guarantee. SNS FIFO topics add group ordering
What silently destroys order
Two consumers read the same lane
▶
They run concurrently, so message 2 can finish before message 1
A failed message is retried later
▶
Messages after it already completed; the retry now lands out of sequence
The key is not chosen carefully
▶
Events of one entity (account, order) are spread over lanes and race each other
Multiple producer instances send for the same key
▶
Their relative order is whatever the network decided, not your business order
You increase the partition count
▶
The same key now hashes to a different partition. Per-key order breaks across the change
Design rule
  • 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.
07
Chapter Seven - Flow Control

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).

Push, pull and the prefetch window
ModelWho controls the rateUsed byProtection against overload
PullThe consumer asks for more when readyKafka (poll), SQS (ReceiveMessage)Built in: a slow consumer simply polls less often
PushThe broker sends as fast as it canRabbitMQ basic.consume, SNS HTTPNeeds a limit: RabbitMQ prefetch (basic.qos) caps unacknowledged messages per consumer
The arithmetic of lag

Lag changes at the difference between the arrival rate and the total processing rate. Doing this sum before production prevents most capacity incidents.

QuantityValueHow
Arrival rate5,000 messages/sMeasured peak
One consumer900 messages/sMeasured: 1.1 ms per message with DB write
Four consumers3,600 messages/s4 x 900
Lag growth with 4+1,400 messages/s = 84,000 per minute5,000 - 3,600
Consumers needed at peak6 minimum (5,000 / 900 = 5.56)Plan for 8 to absorb bursts and catch up
Partitions needed (Kafka)At least 8A 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.

⚠ Scaling consumers does not help if the bottleneck is downstream
  • 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.
08
Chapter Eight - Failure Basics

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.

Retry, then park: the shape of a safe failure path
Queue / topic Consumer ok ack fails retry with backoff N attempts Dead-letter queue alert, inspect, fix, redrive
⚠ The poison message that stops the world
  • 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.
Retry with exponential backoff and jitter

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.

Java - "full jitter" backoff
public final class Backoff { private Backoff() {} /** Delay before retry number {@code attempt} (1, 2, 3, ...), in milliseconds. */ public static long delayMs(int attempt, long baseMs, long capMs) { int shift = Math.min(attempt - 1, 20); // avoid overflow long ceiling = Math.min(capMs, baseMs * (1L << shift)); // base, 2x, 4x, 8x ... up to cap return ThreadLocalRandom.current().nextLong(0, ceiling + 1); } } // attempt 1 -> 0..1000, 2 -> 0..2000, 3 -> 0..4000, ... never more than capMs
QuestionGuidance
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.

09
Chapter Nine - Build It

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.

Java - MiniQueue.java (compiles and runs on Java 17+, no dependencies)
import java.util.ArrayDeque; import java.util.Deque; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicLong; import java.util.function.LongSupplier; /** A queue with at-least-once delivery: receive hides, ack deletes, timeout redelivers. */ public class MiniQueue { public record Delivery(long id, String body, int receiveCount) {} private static final class Entry { final long id; final String body; int receiveCount; long visibleAt; Entry(long id, String body) { this.id = id; this.body = body; } } private final Map<Long, Entry> entries = new LinkedHashMap<>(); // everything not yet acked private final Deque<String> deadLetters = new ArrayDeque<>(); private final long visibilityTimeoutMs; private final int maxReceives; private final LongSupplier clock; private long nextId = 1; public MiniQueue(long visibilityTimeoutMs, int maxReceives, LongSupplier clock) { this.visibilityTimeoutMs = visibilityTimeoutMs; this.maxReceives = maxReceives; this.clock = clock; } public synchronized long send(String body) { Entry e = new Entry(nextId++, body); e.visibleAt = clock.getAsLong(); entries.put(e.id, e); return e.id; } /** First visible message, hidden for the visibility timeout; null if none. */ public synchronized Delivery receive() { long now = clock.getAsLong(); for (Iterator<Entry> it = entries.values().iterator(); it.hasNext(); ) { Entry e = it.next(); if (e.visibleAt > now) continue; // another consumer holds it if (e.receiveCount >= maxReceives) { // received too often: park it deadLetters.add(e.body); it.remove(); continue; } e.receiveCount++; e.visibleAt = now + visibilityTimeoutMs; return new Delivery(e.id, e.body, e.receiveCount); } return null; } /** Consumer finished the work: delete the message for good. */ public synchronized boolean ack(long id) { return entries.remove(id) != null; } public synchronized List<String> deadLetters() { return List.copyOf(deadLetters); } public static void main(String[] args) { AtomicLong now = new AtomicLong(0); MiniQueue q = new MiniQueue(30_000, 3, now::get); q.send("order-1"); q.send("order-2"); Delivery a = q.receive(); System.out.println("t=0s consumer A gets " + a); Delivery b = q.receive(); System.out.println("t=0s consumer B gets " + b); q.ack(b.id()); System.out.println("t=0s B acks order-2; A crashes without acking order-1"); now.set(10_000); System.out.println("t=10s receive -> " + q.receive() + " (still hidden)"); for (int attempt = 2; attempt <= 3; attempt++) { now.addAndGet(31_000); Delivery d = q.receive(); System.out.println("t=" + now.get() / 1000 + "s receive -> " + d + " (redelivered, crashes again)"); } now.addAndGet(31_000); System.out.println("t=" + now.get() / 1000 + "s receive -> " + q.receive()); System.out.println("dead letters: " + q.deadLetters()); } }
Output - a consumer that never acks
t=0s consumer A gets Delivery[id=1, body=order-1, receiveCount=1] t=0s consumer B gets Delivery[id=2, body=order-2, receiveCount=1] t=0s B acks order-2; A crashes without acking order-1 t=10s receive -> null (still hidden) t=41s receive -> Delivery[id=1, body=order-1, receiveCount=2] (redelivered, crashes again) t=72s receive -> Delivery[id=1, body=order-1, receiveCount=3] (redelivered, crashes again) t=103s receive -> null dead letters: [order-1]
What to notice
  • Receive does not remove. The message stays in entries with a future visibleAt. 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.
  • **maxReceives is the redrive policy.** Without it, a poison message cycles forever.
Try this
  • 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.
10
Chapter Ten - Interview

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).
11
Chapter Eleven - Summary

Cheat Sheet and Recap

Cheat sheet
ConcernRuleRemember
Why messagingDecouple in time, level load, fan outIt trades simplicity for resilience
What to sendCommands to one owner; events as factsAlways wrap in an envelope: id, type, version, time
DeliveryDefault to at-least-onceAck after the effect is durable
DuplicatesAssume every message arrives twiceDedup row + effect in one transaction
OrderingOrder per key, parallel across keysRetries and extra consumers reorder
OverloadMeasure lag = arrival - processingPartitions cap Kafka parallelism
FailuresBackoff + jitter, bounded attempts, DLQAlert on DLQ depth
Dual writeNever DB write + publish naivelyUse the outbox (page 07)
∑ Messaging fundamentals - what to remember
  • 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.
Next in this section