So far, all of Kilometre Zero's communication has been synchronous: orders calls inventory over gRPC, waits and receives. That is the right thing when the answer is needed at that very moment, and the wrong thing for everything else. Remember the second symptom of the monolith (lesson 01-06): the external payment gateway slowed down and, because the charge was inside the same transaction as the stock reservation and the creation of the order, every blocked order held on to connections and threads until the whole platform went down. Turning that chain into gRPC calls does not fix it: it would still be a chain of multiplied availabilities and added latencies. What is needed is for orders to be able to say "this has happened" and move on, and for whoever has to react to do so when they can.
That is the job of messaging: an intermediary (the broker) that stores messages and delivers them to their recipients, decoupling the sender from the receiver in time, in space and in pace. In this lesson we will pin down the concepts (producer, consumer, queue, topic, ack, persistence), the two models (point-to-point and publish/subscribe), and the two technologies that dominate the industry, RabbitMQ and Apache Kafka, with their fundamental differences. We will build Kilometre Zero's first asynchronous flow: orders publishes the order.created event, and inventory and analytics each consume it at their own pace. The delivery guarantees and the patterns that make them safe (idempotency, outbox, dead letter queues) are named here and developed in lesson 02-05.
Contents
- Why decouple in time
- The vocabulary of messaging
- Two models: point-to-point and publish/subscribe
- RabbitMQ and AMQP: exchanges, queues, bindings and routing keys
order.createdwith RabbitMQ andpika- Apache Kafka: the distributed log
order.createdwith Kafka- RabbitMQ versus Kafka: selection criteria
- Extending the
km0/docker-compose.yml - Common mistakes and tips
- Exercises
- Conclusion
- Why decouple in time
A synchronous call couples the two participants in three ways:
- In time: both must be alive and available at the same instant. If
analyticsis restarting whenorderstries to notify it of a sale, the notification fails ororderswaits. - In space: the sender needs to know who the receiver is and where it is (address, port). If tomorrow
deliveryalso wants to know about orders,ordershas to change. - In pace: the sender cannot go faster than the slowest receiver. During "Grape Harvest Week",
ordersproduces 1,200 orders per second; ifanalyticscan only process 300,ordersslows down to 300.
A broker breaks all three couplings: orders hands the message to the broker (which is available, because it is replicated infrastructure) and carries on; the broker stores it; the consumers pick it up whenever they like and at whatever pace they can manage; and adding a new consumer does not touch the producer.
flowchart LR
subgraph Before["Synchronous: a chain of dependencies"]
P1[orders] --> I1[inventory]
P1 --> PA1[payments]
P1 --> A1[analytics]
P1 --> R1[delivery]
end
subgraph After["Asynchronous: the broker in the middle"]
P2[orders] -- order.created --> B[(broker)]
B --> I2[inventory]
B --> A2[analytics]
B --> R2[delivery]
end
The cost is just as clear and has to be accepted with open eyes: orders no longer knows whether the message has been processed, or when. Decisions that require an immediate answer (is there stock?) remain synchronous; asynchronous communication is for the consequences of a decision already taken. And one more critical piece of infrastructure appears, with its own availability and its own failure model.
- The vocabulary of messaging
| Term | What it is | At Kilometre Zero |
|---|---|---|
| Message | The unit of data that travels: headers (metadata) + body (serialized bytes, 02-03) | An order.created event with Anna's order in the body |
| Producer (publisher) | Whoever sends messages to the broker | orders |
| Consumer (subscriber) | Whoever receives messages from the broker | inventory, analytics, delivery |
| Broker | The intermediary server that receives, stores and delivers | RabbitMQ or Kafka |
| Queue | An ordered store from which consumers pull messages; each message is normally delivered to one consumer | inventory.orders in RabbitMQ |
| Topic | A named channel to which messages are published and interested parties subscribe; each message can reach several of them | orders.events in Kafka |
| Ack (acknowledgement) | The consumer's confirmation to the broker that it has processed the message; until then the broker keeps it | basic_ack in RabbitMQ, offset commit in Kafka |
| Persistence | The broker writes the message to disk, not just to memory | It survives a broker restart |
| Durability | The queue or topic survives a broker restart (along with its messages, if they are persistent) | The inventory queues must be durable |
| Retention | How long (or how many bytes) the broker keeps messages for | RabbitMQ: until the ack; Kafka: days, even if they have already been read |
| Prefetch | How many messages a consumer may have awaiting ack at once | 1 for safe processing, more for throughput |
- Two models: point-to-point and publish/subscribe
Point-to-point (work queue): producers leave messages on a queue and one or more consumers pull them off; each message is processed by exactly one consumer. This is the model for distributing work: generating the PDF invoices for orders, sending emails, calculating routes. Adding consumers increases the processing rate (competing consumers, which we will cover in 02-05).
Publish/subscribe (pub/sub): producers publish to a topic without knowing who is listening; each subscriber receives its own copy of every message. This is the model for events: "an order has been placed" is of interest to inventory, to analytics and to delivery, and each one does something different with it.
flowchart LR
subgraph PP["Point-to-point"]
Pr1[producer] --> Q[(invoices queue)]
Q --> C1[consumer A]
Q --> C2[consumer B]
note1[each message goes to ONE of the two]
end
subgraph PS["Publish/subscribe"]
Pr2[orders] --> T[(order.created topic)]
T --> S1[inventory]
T --> S2[analytics]
T --> S3[delivery]
note2[each message goes to ALL of them]
end
In practice the two models are combined: each subscriber to a topic is usually a group of instances that compete with each other for that subscriber's messages (three replicas of analytics share out the events between them, but analytics as a whole receives all of them). Both RabbitMQ and Kafka support this combination, with different mechanisms.
- RabbitMQ and AMQP: exchanges, queues, bindings and routing keys
RabbitMQ is a broker that implements AMQP 0-9-1 (Advanced Message Queuing Protocol), a binary protocol over TCP with a very flexible routing model. The key to understanding it is that producers never publish to queues: they publish to an exchange, and the exchange decides which queues to copy the message to according to a set of rules (bindings) and a label on the message (routing key).
flowchart LR
P[orders] -- "routing key: order.created" --> X{{exchange km0.orders<br/>type topic}}
X -- "binding: order.created" --> Q1[(queue inventory.orders)]
X -- "binding: order.#" --> Q2[(queue analytics.orders)]
X -- "binding: order.paid" --> Q3[(queue delivery.orders)]
Q1 --> I[inventory]
Q2 --> A[analytics]
Q3 --> R[delivery]
The four exchange types:
| Type | Routing rule | Use |
|---|---|---|
direct |
The message's routing key must be equal to the binding's | Work queues with an explicit destination |
fanout |
Ignores the routing key: copies to all bound queues | Pure broadcast |
topic |
Dotted routing key (order.created); bindings use wildcards: * (one word), # (zero or more) |
Classified events: order.*, delivery.van-3.# |
headers |
Routes on message headers instead of the routing key | Special cases |
Other RabbitMQ concepts: each consumer keeps a TCP connection and, inside it, one or more channels (lightweight multiplexing); messages are delivered to the consumer by push and remain "unacknowledged" until the ack; if the consumer dies without acknowledging, the broker requeues the message for another consumer; and once acknowledged, the message disappears from the queue. That last property is the fundamental difference from Kafka.
order.created with RabbitMQ and pika
order.created with RabbitMQ and pikapika is the official Python client for RabbitMQ (pip install pika). First, the producer in orders:
# km0/services/orders/rabbit_publisher.py
import json
import time
import uuid
import pika
EXCHANGE = "km0.orders"
def connect():
connection = pika.BlockingConnection(pika.ConnectionParameters("localhost"))
channel = connection.channel()
# Declaring is idempotent: if the exchange exists with the same parameters, nothing happens.
# durable=True: the exchange survives broker restarts.
channel.exchange_declare(exchange=EXCHANGE, exchange_type="topic", durable=True)
return connection, channel
def publish_order_created(channel, order):
event = {
"event_id": str(uuid.uuid4()), # identity of the message (key in 02-05)
"type": "order.created",
"version": 1, # version of the event schema
"timestamp_ms": int(time.time() * 1000),
"source": "orders",
"data": order,
}
channel.basic_publish(
exchange=EXCHANGE,
routing_key="order.created",
body=json.dumps(event).encode("utf-8"),
properties=pika.BasicProperties(
content_type="application/json",
message_id=event["event_id"],
delivery_mode=2, # 2 = persistent: written to disk
),
)
print(f"[orders] published order.created {order['id']}")
if __name__ == "__main__":
connection, channel = connect()
annas_order = {
"id": "P-2026-000123", "customer": "anna", "market": "girona",
"lines": [{"product": "aged-cheese", "quantity": 2, "price_cents": 1450},
{"product": "pink-tomato", "quantity": 3, "price_cents": 320}],
}
publish_order_created(channel, annas_order)
connection.close()Notice the event envelope: besides the order data it carries a unique identifier, a type, a version, a timestamp and a source. It is a convention we will keep for every Kilometre Zero event, and each field has a use that will show up in 02-05 (the event_id for detecting duplicates, the version for evolving the schema).
Now the inventory consumer. Its job, in this first version, is to deduct the reserved stock (later on we will decide whether the synchronous gRPC reservation and the event-driven deduction coexist or one replaces the other; for now, what matters is the mechanism):
# km0/services/inventory/rabbit_consumer.py
import json
import pika
EXCHANGE = "km0.orders"
QUEUE = "inventory.orders"
stock = {"pink-tomato": 120, "zucchini": 80, "aged-cheese": 5,
"fresh-cheese": 30, "crianza-wine": 200}
def on_message(channel, method, properties, body):
event = json.loads(body)
order = event["data"]
for line in order["lines"]:
stock[line["product"]] -= line["quantity"]
print(f"[inventory] processed {event['type']} {order['id']} "
f"(event {event['event_id'][:8]}); aged-cheese={stock['aged-cheese']}")
# Ack AFTER processing: if we die before that, the broker requeues the message
channel.basic_ack(delivery_tag=method.delivery_tag)
connection = pika.BlockingConnection(pika.ConnectionParameters("localhost"))
channel = connection.channel()
channel.exchange_declare(exchange=EXCHANGE, exchange_type="topic", durable=True)
channel.queue_declare(queue=QUEUE, durable=True) # the queue survives a restart
channel.queue_bind(queue=QUEUE, exchange=EXCHANGE, routing_key="order.created")
channel.basic_qos(prefetch_count=1) # one unacknowledged message at a time per consumer
channel.basic_consume(queue=QUEUE, on_message_callback=on_message)
print("[inventory] waiting for order.created on", QUEUE)
channel.start_consuming()And the analytics one, which wants all order events, not just the creation ones, to accumulate statistics:
# km0/services/analytics/rabbit_consumer.py
import json
from collections import Counter
import pika
sales_by_product = Counter()
def on_message(channel, method, properties, body):
event = json.loads(body)
if event["type"] == "order.created":
for line in event["data"]["lines"]:
sales_by_product[line["product"]] += line["quantity"]
print(f"[analytics] {event['type']} -> {dict(sales_by_product)}")
channel.basic_ack(delivery_tag=method.delivery_tag)
connection = pika.BlockingConnection(pika.ConnectionParameters("localhost"))
channel = connection.channel()
channel.exchange_declare(exchange="km0.orders", exchange_type="topic", durable=True)
channel.queue_declare(queue="analytics.orders", durable=True)
channel.queue_bind(queue="analytics.orders", exchange="km0.orders", routing_key="order.#")
channel.basic_qos(prefetch_count=50) # analytics tolerates batches: more throughput
channel.basic_consume(queue="analytics.orders", on_message_callback=on_message)
channel.start_consuming()Key points about the three programs:
- Each consumer declares its own queue and binds it to the exchange with the pattern it cares about.
inventoryonly wantsorder.created;analyticswantsorder.#(created, paid, cancelled...). The producer knows nothing about either queue: that is decoupling in space. - The message is copied to every bound queue:
inventoryandanalyticseach receive their own copy (pub/sub). If you start two instances ofinventory'srabbit_consumer.py, they will share theinventory.ordersqueue and each message will go to one of them (point-to-point within the subscriber). - Start the consumers after publishing and you will see that they receive the message all the same: it was stored in the queue. But if the queue did not exist when the message was published (because the consumer had never started), the message was discarded: the exchange had nowhere to copy it. That is why the queues of critical consumers must be declared at deployment time, not when the first consumer starts up.
durable=True+delivery_mode=2are the two halves of surviving a broker restart: the queue and the message. One without the other is no use.prefetch_count=1ininventory: the broker does not send it the next message until it acknowledges the current one, which stops an instance from hoarding messages that it cannot process if it dies. It costs throughput;analytics, which can afford to reprocess batches, uses 50.
The management web interface (http://localhost:15672, username and password km0) shows exchanges, queues, pending messages and connected consumers: it is the first diagnostic tool to reach for.
- Apache Kafka: the distributed log
Kafka was born at LinkedIn (2011) with a different idea: instead of a queue from which messages disappear once consumed, a log, that is, an append-only file in which each message occupies a fixed position (offset) and stays there for a retention period (7 days by default) regardless of whether anyone has read it. Consumers do not pull messages off: they read the log from a position and remember how far they have got.
flowchart LR
subgraph T["topic orders.events (3 partitions)"]
P0["partition 0: [0][1][2][3][4] →"]
P1["partition 1: [0][1][2] →"]
P2["partition 2: [0][1][2][3] →"]
end
Pr[orders<br/>key = order id] --> T
subgraph G1["inventory group"]
C1[instance 1] -.- P0
C1 -.- P1
C2[instance 2] -.- P2
end
subgraph G2["analytics group"]
C3[single instance] -.- P0
C3 -.- P1
C3 -.- P2
end
The concepts you need to master:
- Topic: the logical name of the stream (
orders.events). - Partition: each topic is split into N independent logs. It is the unit of parallelism (each partition is read by a single instance of each group) and of ordering (messages are ordered within a partition, not across partitions).
- Partition key: the producer can assign a key to each message; Kafka computes
hash(key) mod Nto choose the partition. All messages with the same key go to the same partition and are therefore read in order. Using the order identifier as the key,order.created,order.paidandorder.cancelledforP-2026-000123always reach the same consumer in that order. Without a key, they are spread around and the order is lost. (Hashing to distribute keys across partitions is the same problem as data partitioning, lesson 04-01.) - Offset: the position of a message within its partition. It is an increasing integer; consumers use it as a bookmark.
- Consumer group: instances that share a
group.idsplit the topic's partitions between them (point-to-point within the group); different groups each read the whole topic (pub/sub between groups). The offset commit is the equivalent of the ack: "theinventorygroup has processed up to offset 4 of partition 0". - Retention: messages are deleted by age or by size, not by consumption. A new consumer can read the full history (
auto.offset.reset=earliest); one that was down for three days catches up on what it missed; andanalyticscan re-read the whole month if its logic changes. This is a capability that RabbitMQ does not have. - Replicas: each partition is replicated across several brokers (replication factor 3 in production); one is the leader and serves reads and writes. Replication and its guarantees are studied in 03-04.
order.created with Kafka
order.created with KafkaWe will use confluent-kafka (pip install confluent-kafka), the most efficient Python client (it wraps the C library librdkafka); kafka-python is a pure-Python alternative with a similar API. First we create the topic with 6 partitions (with a single development broker, replication factor 1):
docker compose exec kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 \
--create --topic orders.events --partitions 6 --replication-factor 1The producer:
# km0/services/orders/kafka_publisher.py
import json
import time
import uuid
from confluent_kafka import Producer
TOPIC = "orders.events"
producer = Producer({
"bootstrap.servers": "localhost:9092",
"acks": "all", # the leader and the in-sync replicas confirm before replying
"client.id": "orders",
})
def on_delivery(err, msg):
"""Asynchronous callback: Kafka accumulates messages in batches and confirms later."""
if err is not None:
print(f"[orders] ERROR while publishing: {err}")
else:
print(f"[orders] confirmed in {msg.topic()}[{msg.partition()}] offset {msg.offset()}")
def publish_order_created(order):
event = {"event_id": str(uuid.uuid4()), "type": "order.created", "version": 1,
"timestamp_ms": int(time.time() * 1000), "source": "orders", "data": order}
producer.produce(
TOPIC,
key=order["id"], # same key -> same partition -> ordering
value=json.dumps(event).encode("utf-8"),
headers=[("type", "order.created"), ("event_id", event["event_id"])],
callback=on_delivery,
)
producer.poll(0) # serves pending callbacks without blocking
if __name__ == "__main__":
for i, (customer, product, quantity) in enumerate(
[("anna", "aged-cheese", 2), ("mark", "crianza-wine", 6), ("lucy", "pink-tomato", 3)]):
publish_order_created({"id": f"P-2026-00012{3 + i}", "customer": customer, "market": "girona",
"lines": [{"product": product, "quantity": quantity}]})
producer.flush() # waits until everything is confirmed before exitingproduce does not send: it queues the message in an internal buffer that a background thread sends in batches (which is how Kafka achieves hundreds of thousands of messages per second). flush() blocks until all of them are confirmed; acks=all makes "confirmed" mean written to the leader and to the in-sync replicas. Without flush at the end of the program, the messages in the buffer would be lost.
The inventory consumer:
# km0/services/inventory/kafka_consumer.py
import json
from confluent_kafka import Consumer, KafkaError
consumer = Consumer({
"bootstrap.servers": "localhost:9092",
"group.id": "inventory", # all inventory instances share a group
"auto.offset.reset": "earliest", # a new group starts from the beginning of the log
"enable.auto.commit": False, # we will commit ourselves, after processing
})
consumer.subscribe(["orders.events"])
stock = {"pink-tomato": 120, "zucchini": 80, "aged-cheese": 5, "fresh-cheese": 30, "crianza-wine": 200}
try:
while True:
msg = consumer.poll(timeout=1.0) # pull: the consumer asks, the broker does not push
if msg is None:
continue
if msg.error():
if msg.error().code() != KafkaError._PARTITION_EOF:
print("[inventory] error:", msg.error())
continue
event = json.loads(msg.value())
if event["type"] == "order.created":
for line in event["data"]["lines"]:
stock[line["product"]] -= line["quantity"]
print(f"[inventory] partition {msg.partition()} offset {msg.offset()} "
f"key {msg.key().decode()}: aged-cheese={stock['aged-cheese']}")
consumer.commit(message=msg) # equivalent to the ack: "processed up to here"
finally:
consumer.close()For analytics, the same code with "group.id": "analytics" and its own logic: since it is a different group, it receives all the messages again, regardless of what inventory has committed. Try starting two instances of the inventory consumer: you will see in the logs how Kafka reassigns the 6 partitions (3 and 3) between them, and how each order always goes to the same instance because of its key. Stop one and you will see the partitions go back to the other: this is group rebalancing, which gives consumers fault tolerance without the producer ever noticing.
One detail about the commit: commit(message=msg) marks up to that offset in that partition, not "this message". If messages 3, 4 and 5 are processed and 5 is committed, after a restart consumption resumes from 6. If 5 is committed without 4 having been processed (for example, when processing in parallel), 4 is lost. The commit is a position marker, and the consequences of committing before or after processing (losing or duplicating) are exactly the delivery guarantees that 02-05 analyses.
- RabbitMQ versus Kafka: selection criteria
| Criterion | RabbitMQ | Apache Kafka |
|---|---|---|
| Model | Smart queue: the broker routes and tracks every message | Dumb log: the broker stores; the consumer remembers how far it has got |
| The message after being consumed | It is deleted | It stays until retention expires |
| Routing | Very flexible (exchanges, wildcards, headers) | By topic and partition; filtering is done by the consumer |
| Ordering | Per queue, with one consumer | Per partition (per key) |
| Delivery | Push to the consumer | Pull by the consumer |
| Typical throughput | Tens of thousands of messages/s | Hundreds of thousands to millions of messages/s |
| Re-reading the history | No | Yes (by design) |
| Slow consumers | Messages pile up in the queue; this can degrade the broker | They affect neither the broker nor other groups |
| Priorities, TTL, dead letter queues | Native | Not native (built with topics, 02-05) |
| Request/reply | Convenient (reply_to, 02-05) |
Awkward |
| Operational complexity | Low to medium | Medium to high (partitions, rebalances, retention), although KRaft has reduced it |
| Protocol | AMQP (also MQTT and STOMP with plugins) | Its own, binary over TCP |
| Data ecosystem | Small | Huge: Kafka Connect, Streams, integration with Spark and Flink (Module 5) |
| Role at Kilometre Zero | Work queues (invoices, emails), internal request/reply | Domain events (orders.events, inventory.events, delivery telemetry) |
Decision criteria:
- Are the messages events that several systems will want, perhaps in the future, perhaps re-read? Kafka. This is why the target architecture in 01-06 chooses Kafka for domain events:
analyticswill want to reprocess the month; a new service will want the history. - Are they tasks to be run once and forgotten, with fine-grained routing, priorities or a reply? RabbitMQ.
- Does per-entity ordering matter (all the events of one order, in order)? Kafka with a partition key provides it naturally.
- Volume? Below a few thousand messages per second, either will do; far above that, Kafka.
- Who is going to operate it? A badly operated broker is worse than none. If the team is small, start with just one, and with a managed service.
Kilometre Zero, following the target architecture, will use Kafka for domain events and keeps RabbitMQ for work queues and for the request/reply pattern that we will see in 02-05. Having both is not mandatory; many systems live happily with just one.
- Extending the
km0/ docker-compose.yml
km0/ docker-compose.ymlWe add the two brokers to the environment from 01-06. Kafka runs in KRaft mode (without ZooKeeper, the standard mode since version 3.x), with two listeners: an internal one for the services on the Compose network and another for the scripts we run from the host.
rabbitmq:
image: rabbitmq:3.13-management
environment:
RABBITMQ_DEFAULT_USER: km0
RABBITMQ_DEFAULT_PASS: km0_dev
ports:
- "5672:5672" # AMQP
- "15672:15672" # management web interface
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 5
kafka:
image: apache/kafka:3.8.0
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: INTERNAL://:19092,EXTERNAL://:9092,CONTROLLER://:9093
KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:19092,EXTERNAL://localhost:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 # a single broker in development
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_LOG_RETENTION_HOURS: 168 # 7 days
ports:
- "9092:9092"
volumes:
- kafka_data:/var/lib/kafka/data
volumes:
postgres_data:
rabbitmq_data:
kafka_data:Services running inside Compose (such as inventory from 02-03 onwards) will use kafka:19092 and rabbitmq:5672; scripts launched from the host, localhost:9092 and localhost:5672. The plain-text passwords are for development only: secrets management is the subject of 06-04. With docker compose up -d rabbitmq kafka and the three scripts from this chapter, you have Kilometre Zero's first asynchronous flow up and running.
Common Mistakes and Tips
- Using messaging for something that needs an immediate answer. "Is there stock?" cannot wait for a consumer to process an event. Synchronous for decisions, asynchronous for consequences.
- Non-durable queues or non-persistent messages in critical flows. A broker restart takes the orders with it. Durable + persistent, always, except for disposable telemetry.
- Acknowledging (ack/commit) before processing. If the consumer dies after acknowledging and before finishing, the message is lost. Acknowledging after processing causes duplicates in the opposite case, and 02-05 explains how to live with that.
- Forgetting
flush()in the Kafka producer.produceonly queues; a process that exits withoutflushloses the buffer, with no error. - Publishing without a partition key and expecting ordering. Without a key, the events of a single order are spread across partitions and
order.paidmay be processed beforeorder.created. - One topic per event type in Kafka. It breaks the ordering between events of the same entity (they are in different topics) and multiplies the partitions. One topic per entity (
orders.events) with the type in a header is usually better. - Consumers that declare queues that "ought to exist". If
inventorynever started, its RabbitMQ queue does not exist and the events published in the meantime are lost. Declare the topology (exchanges, queues, bindings, topics) at deployment time. - Huge messages. Neither RabbitMQ nor Kafka is built to carry 5 MB product photos. The message carries a reference to the object store (04-03).
- Tip: always put a standard envelope on your events (
event_id,type,version,timestamp_ms,source,data). It costs five lines and you will be grateful for it in every pattern in 02-05 and in every investigation in 07-02. - Tip: keep an eye on the RabbitMQ interface and on the lag of the Kafka groups (
kafka-consumer-groups.sh --describe) from day one. A queue that keeps growing or a lag that does not go down is the first symptom of a sick consumer, long before anyone complains.
Exercises
Exercise 1: Routing with a topic exchange
delivery wants to receive only orders that have already been paid (order.paid), and customer-support wants all the cancellation events of any entity (order.cancelled, delivery.cancelled, ...). Write the queue and binding declarations for both on the km0.orders exchange (assume that delivery events are also published to it with routing keys delivery.<type>), and say what each one would receive if the following were published, in order: order.created, order.paid, delivery.assigned, delivery.cancelled, order.cancelled.
Exercise 2: Partitions, keys and ordering
The orders.events topic has 6 partitions and inventory has 3 instances in the same group. (a) How many partitions does each instance read? (b) If 4 more instances are added (7 in total), what happens? (c) orders publishes order.created and order.cancelled for P-2026-000123 with key P-2026-000123, and order.created for P-2026-000124. Is it guaranteed that inventory processes the creation of 123 before its cancellation? And before the creation of 124? (d) A developer suggests using customer as the key instead of the order id, "so that all of Anna's orders go to the same consumer". What is gained and what is put at risk?
Exercise 3: Choosing a broker
For each Kilometre Zero flow, say RabbitMQ or Kafka and justify it using the table in section 8:
- Generating the PDF invoice for each paid order (heavy task, once, no ordering).
- The stock events (
inventory.events) thatcatalogconsumes for the "only a few left" indicator and thatanalyticswants to reprocess every month to study stock-outs. - Positions from 400 couriers, one per second each, for the live map and for later route analysis.
- An internal service that needs to ask
paymentswhether a card is blocked, and wait for the answer.
Solutions
Solution 1:
# delivery: paid orders only
channel.queue_declare(queue="delivery.orders", durable=True)
channel.queue_bind(queue="delivery.orders", exchange="km0.orders", routing_key="order.paid")
# customer support: any cancellation of any entity
channel.queue_declare(queue="support.cancellations", durable=True)
channel.queue_bind(queue="support.cancellations", exchange="km0.orders", routing_key="*.cancelled")With the five events published: delivery.orders receives only order.paid. support.cancellations receives delivery.cancelled and order.cancelled (the * wildcard matches exactly one word: order or delivery). Neither of the two receives order.created or delivery.assigned. And the existing queues keep receiving what is theirs: inventory.orders only order.created; analytics.orders (order.#) the three order events, but not the delivery ones. Each consumer decides what it wants; the producer does not change.
Solution 2:
(a) Kafka splits the 6 partitions among the 3 instances: 2 each. (b) With 7 instances and 6 partitions, 6 instances read one partition each and the seventh sits idle: the number of partitions is the upper limit on a group's parallelism. To make use of more instances, the topic would have to be created with more partitions (and they cannot be added without changing the key→partition assignment of future messages, which temporarily breaks per-key ordering). (c) Yes for 123: both messages have the same key, they go to the same partition, and a partition is read by a single instance in order. No with respect to 124: it may be in another partition read by another instance; their relative order is undefined, and it does not matter, because they are independent orders. (d) The gain is that all the events of a given customer are processed in order and on the same instance (useful if there were per-customer rules, such as a limit on orders per hour). Two things are put at risk: unbalanced partitions (a business customer who places 30% of the orders concentrates 30% of the load on one partition and one instance) and, in a sense, unnecessary ordering: Anna's orders are independent of each other, so serialising them adds nothing and reduces parallelism. The key should be the entity whose ordering matters, neither finer nor coarser.
Solution 3:
- RabbitMQ: a classic work queue; each invoice is generated by one instance and disappears; no ordering or re-reading; with priorities if needed (campaign invoices first). Kafka could do it too, but it adds nothing here.
- Kafka: domain events with two consumers with different needs (one in real time, the other re-reading a month), per-product ordering (key = product slug) and long retention. It is exactly the use case it was designed for.
- Kafka (with an MQTT bridge in front for the mobile leg, 08-02): high volume (400 messages/s sustained, far more during a campaign), two consumers (live map and route analysis), per-courier ordering (key =
van-3), and retention for the later analysis, which is a batch job from Module 5. RabbitMQ would pile up the positions and would not allow them to be re-read. - RabbitMQ, if you decide to do it through messaging: the request/reply pattern with
reply_toandcorrelation_id(02-05) is natural in AMQP and awkward in Kafka. But the prior question is whether it should be messaging at all: it is a synchronous query that needs an immediate answer, and a gRPC call topayments(02-03) is simpler and faster. Messaging would only be justified ifpaymentswere slow or intermittent and you wanted to absorb spikes, or if the answer could take a while and the requester could wait without blocking.
Conclusion
This lesson has introduced the second half of communication between services. A synchronous call couples in time, in space and in pace; a broker breaks all three couplings in exchange for the sender no longer knowing when, or whether, its message will be processed. With that vocabulary (producer, consumer, queue, topic, ack, persistence, durability, retention) we have distinguished the point-to-point model, for distributing work, from the publish/subscribe model, for broadcasting events, and we have seen how each broker combines them. RabbitMQ is a smart queue with flexible routing (exchanges, bindings, routing keys with wildcards) that deletes messages once they are acknowledged; Kafka is a distributed log in which messages stay put, consumers remember their offset, partitions provide parallelism and keys provide per-entity ordering, and any group can re-read the history. Kilometre Zero now has its first asynchronous flow: orders publishes order.created with a standard envelope, inventory and analytics consume it at their own pace, and both brokers are in the docker-compose.yml.
But we have deliberately left several questions open. What happens if inventory dies after deducting the stock and before acknowledging the message? The broker will redeliver it and the stock will be deducted twice, the same problem we left pending in 01-04 and in 02-02. What happens if orders saves the order in PostgreSQL and crashes before publishing the event, or publishes the event and then the COMMIT fails? And what about a malformed message that makes the consumer fail over and over again, blocking everything queued behind it? These questions are the delivery guarantees and the patterns that make them manageable (idempotent consumers, transactional outbox, dead letter queues), and they are the subject of the module's last lesson: Asynchronous Communication Patterns.
Distributed Architectures Course
Module 1: Introduction to Distributed Systems
- Basic Concepts of Distributed Systems
- Distributed System Models
- Advantages and Challenges of Distributed Systems
- The Fallacies of Distributed Computing
- Time, Clocks and Event Ordering
- From Monolith to Distributed Platform: the Kilometre Zero Case
Module 2: Communication in Distributed Systems
- Communication Protocols
- RPC and RMI
- gRPC and Data Serialization
- Messaging and Message Queues
- Asynchronous Communication Patterns
Module 3: Consistency and Replication
- Consistency Models
- The CAP Theorem and PACELC
- Consensus Algorithms
- Data Replication
- Distributed Transactions and Sagas
Module 4: Distributed Storage
- Data Partitioning and Consistent Hashing
- Distributed File Systems
- Object Storage
- Distributed Databases
- Distributed Caches
Module 5: Distributed Computing
- Distributed Computing Models
- MapReduce and Hadoop
- Spark and In-Memory Computing
- Stream Processing
- Job Scheduling and Data Pipelines
Module 6: Security in Distributed Systems
- Authentication and Authorization
- Encryption and Data Protection
- Identity Management
- Service-to-Service Security: mTLS and Secrets Management
- API Gateways, Rate Limiting and Auditing
Module 7: Monitoring and Maintenance
- Monitoring Distributed Systems
- Centralized Logs and Distributed Tracing
- Failure Management and Recovery
- Resilience Patterns: Timeouts, Retries and Circuit Breakers
- Automation and Orchestration
- Testing Distributed Systems and Chaos Engineering
