The previous five lessons have laid the foundations: what a distributed system is, which models describe it, what you gain and lose by distributing, which fallacies need to be banished and why time is a problem. This closing lesson puts them to work on the case that will stay with us for the rest of the course. We are going to examine the Kilometre Zero monolith in detail, the specific symptoms pushing it to change and the target architecture we will build module by module.

It is important to understand the nature of this lesson: it is a map, not the journey. No specific technology is explained here; we decide which pieces the platform needs, why, and in which lesson of the course each one is developed. We will also set up the minimal working environment (the folder structure of the km0/ project and a docker-compose.yml with just PostgreSQL and the Python application), which will be the starting point on which services will be added.

Contents

  1. Inside the Kilometre Zero monolith
  2. The symptoms forcing the evolution
  3. Design principles for the transition
  4. The target architecture
  5. Mapping the architecture to the course
  6. A gradual path, not a "big bang"
  7. Setting up the working environment: the km0/ project
  8. Common mistakes and tips
  9. Design exercises
  10. Conclusion

  1. Inside the Kilometre Zero monolith

Recall the starting point (lesson 01-01): a Python application in a single process, a PostgreSQL database and a single server. Let's now look at it in more detail, because to decide how to split it up we have to understand how it is built.

Internal modules

The code is organised into six Python modules, which correspond to the functional domains of the business:

Module Responsibility Main tables Who uses it
catalog Products, producers, categories, photos, search products, producers, categories Customers (many reads), producers (few writes)
orders Basket, order creation and life cycle orders, order_lines Customers, customer support
inventory Stock per product and per producer, reservations stock, stock_movements orders, producers
payments Charges through the external gateway, refunds payments, refunds orders
delivery Courier assignment, routes, real-time position deliveries, couriers, positions Couriers (mobile app), customers (tracking)
analytics Sales reports, best-selling products, demand forecasting Queries over all of the above Management, producers

How they interact

Interactions between modules are Python function calls, and operations that touch several modules rely on a single database transaction. The flow for confirming an order is the clearest example:

sequenceDiagram
    participant C as Customer (Anna)
    participant P as orders
    participant I as inventory
    participant PA as payments
    participant DB as PostgreSQL
    C->>P: confirm_order(basket)
    P->>DB: BEGIN
    P->>I: reserve_stock(lines)
    I->>DB: UPDATE stock ...
    P->>PA: charge(amount, card)
    PA->>PA: call to external gateway (HTTPS)
    PA->>DB: INSERT payment
    P->>DB: INSERT order
    P->>DB: COMMIT
    P-->>C: order confirmed

Notice a detail that will prove decisive: the call to the external payment gateway happens inside the database transaction. If the gateway takes 8 seconds, the transaction (and the locks on the stock rows) lasts 8 seconds. And if the gateway fails, the whole transaction is rolled back, which is correct, but in the meantime it has been locking the stock for every other customer.

Deployment

A single artefact (a Python package) that is installed on the server with a script. Deploying means stopping the process, installing and starting up again: about 40 seconds of total unavailability. Deployments happen on Tuesday and Thursday nights, with all the changes from all the teams accumulated since the previous deployment.

  1. The symptoms forcing the evolution

There is no single reason to transform Kilometre Zero, but five distinct symptoms, each of which pushes towards a different piece of the target architecture. It is worth identifying them precisely because each symptom justifies a specific decision, and decisions that do not respond to any symptom are gratuitous complexity.

Symptom 1: campaign spikes bring everything down

We have already seen it: Grape Harvest Week multiplied traffic by 15 and the server went down completely. The post-mortem showed that 92% of requests were catalog reads (listings, searches, product pages with photos). The rest of the platform went down because it shared a process and a database with the catalog.

What it calls for: being able to scale the catalog independently, serving reads from a cache and photos from separate storage, and isolating failures so that a catalog overload does not affect orders or delivery.

Symptom 2: a payments failure that brought everything down

One Tuesday, the external payment gateway started responding with a 30-second delay. Each order confirmation kept a transaction open (and stock locks held) for those 30 seconds. Within four minutes, PostgreSQL hit its connection limit and the whole platform, including catalog pages that have nothing to do with payments, stopped responding. A failure at an external provider turned into a total outage.

What it calls for: making payments an isolated component with its own resources, containing its slowness or failure (timeouts, graceful degradation), and ensuring that order confirmation does not depend on a single transaction spanning external systems.

Symptom 3: teams tread on each other's toes when deploying

Kilometre Zero already has four teams (catalog and producers, orders and payments, delivery, data). Every deployment carries everyone's changes, so a bug in the delivery module forces the catalog improvements to be rolled back too. The delivery team wants to deploy several times a day; the payments team, which is subject to audits, wants long, controlled cycles. The result is that nobody deploys as often as they need to, and every Tuesday deployment is a tense event.

What it calls for: independent deployment units per domain, with their own cycles, tests and owners, and a deployment platform that makes zero-downtime deployments possible.

Symptom 4: real-time courier telemetry

Kilometre Zero has gone from 12 to 140 couriers, and each one sends their position every 5 seconds: 28 positions per second, 2.4 million a day, which are inserted into the positions table of the same database that handles orders. The table grows by 800 MB a day and the inserts compete with order transactions. On top of that, customers want to see the courier moving on the map in real time, which under the request-response model forces the app to poll every few seconds, multiplying the load.

What it calls for: a different communication channel for high-frequency event streams (not a relational table), asynchronous, bidirectional communication with the apps, and storage suited to high-volume time series.

Symptom 5: sales analytics chokes production

Every night, the analytics module runs heavy queries against the order and stock tables to generate reports. The queries take 3 hours and, during that time, the database is slow for everyone. Producers are asking for richer reports (seasonal demand forecasting, comparisons between cities) that the data team does not dare to implement because every new query puts production at risk.

What it calls for: separating the analytical workload from the transactional one, with a copy of the data in a system designed for large-scale processing, fed by the events the other services produce.

  1. Design principles for the transition

Before drawing the target architecture, let's set out the principles that follow from this module and will guide every decision:

  1. Each service owns its data. No service accesses another's tables directly. If analytics needs the orders, it receives them as events or through the orders API. This is what makes independent deployment and scaling possible (symptoms 1 and 3), and what forces us to give up the single transaction (which is replaced by the techniques of Module 3).
  2. Failure isolation. A failure or an overload in one service must not spread to the others (symptoms 1 and 2). This requires separate resources, timeouts on every remote call (lesson 01-04) and graceful degradation: if delivery goes down, people can still buy; if payments goes down, people can still browse the catalog.
  3. Synchronous only when the answer is needed now. Checking the stock before confirming an order needs an immediate answer: synchronous. Telling analytics that an order has been created does not: asynchronous, through events. Every synchronous call avoided is one less network boundary in the availability chain (lesson 01-03).
  4. Take the opposite of each fallacy as true. The network will fail, it will have latency, it will not be secure, the topology will change. Every communication between services carries a timeout, idempotent retries, encryption and dynamic discovery.
  5. Observability from the start. With six services, without metrics, correlated logs and distributed traces there is no way of knowing what is going on. It is not a later add-on: it is part of the design.
  6. Gradual evolution. Not everything is rewritten at once. One service is extracted and verified, then the next one is extracted. The monolith keeps running throughout the transition.

  1. The target architecture

With the symptoms and the principles on the table, this is the platform the course will build:

flowchart TB
    subgraph Clients
        WEB[Web browser]
        APP[Customer mobile app]
        COU[Courier app]
    end

    GW[API Gateway<br/>authentication, rate limiting]
    WEB --> GW
    APP --> GW
    COU -->|MQTT / WebSocket| RT[Real-time server]

    subgraph Services["Services (Kubernetes)"]
        CAT[catalog]
        ORD[orders]
        INV[inventory]
        PAY[payments]
        DEL[delivery]
        ANA[analytics]
    end

    GW --> CAT
    GW --> ORD
    GW --> DEL
    ORD -->|synchronous gRPC| INV
    ORD -->|synchronous gRPC| PAY
    RT --> DEL

    subgraph Bus["Event bus (Kafka)"]
        T1[(orders.events)]
        T2[(delivery.positions)]
        T3[(inventory.events)]
    end
    ORD -.->|OrderCreated, OrderPaid| T1
    INV -.->|StockUpdated| T3
    DEL -.->|PositionUpdated| T2
    T1 -.-> ANA
    T2 -.-> ANA
    T3 -.-> CAT
    T1 -.-> DEL

    subgraph Data["Storage per service"]
        PGC[(PostgreSQL<br/>catalog)]
        RED[(Redis<br/>catalog cache)]
        OBJ[(Object storage<br/>photos)]
        CAS[(Cassandra<br/>orders)]
        PGI[(Replicated PostgreSQL<br/>inventory)]
        PGP[(PostgreSQL<br/>payments)]
        TS[(Cassandra<br/>positions)]
        DL[(Data lake + Spark/Flink<br/>analytics)]
    end
    CAT --> PGC
    CAT --> RED
    CAT --> OBJ
    ORD --> CAS
    INV --> PGI
    PAY --> PGP
    PAY -->|HTTPS| EXT[External payment gateway]
    DEL --> TS
    ANA --> DL

    subgraph CrossCutting["Cross-cutting"]
        OBS[Observability<br/>Prometheus, Grafana, OpenTelemetry]
        SEC[Security<br/>OIDC, mTLS, secrets]
    end

Let's walk through the decisions, each one tied to its symptom:

The six services

The service boundaries coincide with the modules of the monolith, and that is no accident: the modules already reflected the business domains and the teams. A good modular monolith is the best preparation for a services architecture. Each service has its own deployment, its own scaling and its own storage (symptoms 1, 2 and 3).

Synchronous communication: gRPC

For interactions that need an immediate answer (ordersinventory to reserve stock; orderspayments to charge), we will use RPC with gRPC: typed calls, with an explicit contract and efficient serialization. Every call carries a timeout, and operations with side effects are idempotent (Module 2).

Asynchronous communication: events over Kafka

For everything that does not need an immediate answer, services publish events on a bus (Kafka): OrderCreated, OrderPaid, StockUpdated, PositionUpdated. Whoever is interested subscribes: analytics consumes everything; catalog consumes StockUpdated to show availability; delivery consumes OrderPaid to plan the delivery. This decouples the services in time (if analytics is down, the events wait on the bus) and solves symptom 5 (the data reaches analytics without querying the production database) and part of symptom 4 (positions are an event stream, not rows in a transactional table).

Storage per service

Each service chooses the storage that best fits its workload:

Service Storage Why
catalog PostgreSQL + Redis as a cache + object storage for photos Many repeated reads (cache), simple relational data, large photos kept out of the database (symptom 1)
orders Cassandra High write volume, queries by customer and by date, the need to scale horizontally and for very high availability
inventory Replicated PostgreSQL Needs strong consistency (never sell the last unit twice) and transactions; replication provides availability
payments PostgreSQL Transactions, auditing, moderate volume
delivery Cassandra (positions, time series) + PostgreSQL (assignments) 2.4 million positions a day; massive writes keyed by courier and time (symptom 4)
analytics Data lake on object storage + batch processing (Spark) and stream processing (Flink/Kafka Streams) Large-scale queries kept apart from production (symptom 5)

The consequence of this decision is that the single order-confirmation transaction disappears: reserving stock (the inventory PostgreSQL), charging (the payments PostgreSQL) and creating the order (Cassandra) are three operations on three systems. Coordinating them without a global transaction is the problem addressed by sagas (03-05).

Real time for couriers

The courier apps send positions over MQTT (a lightweight protocol for mobile networks) and customers receive updates over WebSocket, through a real-time server that publishes to the bus and feeds from it (symptom 4; lessons 02-01 and 08-02).

Analytics: batch and streaming

analytics stops querying production. The events on the bus are stored in a data lake, on top of which run batch jobs (nightly reports, with Spark) and stream processing (real-time campaign metrics, with Flink or Kafka Streams), orchestrated by a scheduler (symptom 5; Module 5).

Security, observability, deployment

A gateway authenticates users (OIDC) and limits traffic; services authenticate to one another with mTLS; secrets are managed centrally (Module 6). Metrics, logs and distributed traces are collected from all the services (Module 7). Everything is deployed as containers on Kubernetes, with a cloud provider, with infrastructure defined as code (07-05, 08-03).

  1. Mapping the architecture to the course

This table is the compass for the course: each component of the target architecture, and the lesson in which it is built or explained.

Component / decision Module Lesson
Sockets, TCP/UDP, HTTP, MQTT for couriers 2 02-01
Synchronous calls between services (RPC) 2 02-02
gRPC and contract-based serialization (ordersinventory, payments) 2 02-03
Kafka event bus, RabbitMQ queues 2 02-04
Idempotency, outbox, dead-letter queues, competing consumers 2 02-05
What guarantees each storage system gives (consistency models) 3 03-01
Why inventory (consistency) and orders (availability) choose differently: CAP and PACELC 3 03-02
How the replicated PostgreSQL leader is elected and how Kafka coordinates: consensus (Raft) 3 03-03
Replication of the inventory PostgreSQL; conflict detection 3 03-04
Confirming an order without a global transaction: sagas 3 03-05
How Cassandra distributes orders and positions: partitioning and consistent hashing 4 04-01
Distributed file systems for the data lake 4 04-02
Product photos in object storage (S3/MinIO) 4 04-03
Cassandra for orders and for the delivery positions 4 04-04
Redis as the catalog cache 4 04-05
Distributed computing models for analytics 5 05-01
Batch reports: MapReduce and Hadoop 5 05-02
Demand forecasting with Spark 5 05-03
Real-time campaign metrics: Flink / Kafka Streams 5 05-04
Orchestrating nightly jobs with Airflow 5 05-05
Customer authentication and roles (JWT) 6 06-01
Encryption of payment data and positions 6 06-02
User and producer identity (OAuth2/OIDC) 6 06-03
mTLS between services, secrets management 6 06-04
Gateway, rate limiting, auditing 6 06-05
Metrics with Prometheus and Grafana 7 07-01
Centralized logs and traces with OpenTelemetry 7 07-02
PostgreSQL failover, Flink checkpoints 7 07-03
Timeouts, retries, circuit breaker on orderspayments 7 07-04
Deployment on Kubernetes, Ansible 7 07-05
Testing and chaos engineering 7 07-06
Microservices: boundaries, contracts, organisation 8 08-01
WebSockets and MQTT for real-time tracking 8 08-02
Deployment on AWS/GCP 8 08-03
Serverless functions for photo resizing; edge for the markets 8 08-04
Kilometre Zero end to end 8 08-05

  1. A gradual path, not a "big bang"

The target architecture is the destination, not the first step. Rewriting everything in one go is the surest way to fail: for months there would be nothing to deploy, and at the end everything would have to be migrated at once. Instead, Kilometre Zero will follow the pattern known as the strangler fig: one service is extracted from the monolith, traffic is redirected to it and the monolith loses that responsibility; then the same is done with the next one. At every step, the system works and can be deployed.

Phase What is extracted Symptom it solves Course modules
1 catalog (with Redis and photo storage) 1: campaign spikes 2, 4
2 payments (isolated, with timeouts and a circuit breaker) 2: payments failure that brings everything down 2, 7
3 delivery with the event bus and the real-time channel 4: telemetry 2, 4, 8
4 orders and inventory (with sagas and replication) 3: independent deployments 3, 4
5 analytics on top of the bus and the data lake 5: analytics 5
Cross-cutting Security, observability, Kubernetes All 6, 7

We start with the catalog because it causes the most serious symptom, has the fewest dependencies (reads only) and carries the least risk: if the new service fails, traffic is pointed back at the monolith.

  1. Setting up the working environment: the km0/ project

To follow the course hands-on, we will create a project that we will keep extending. In this module it contains only the starting point: the monolith and its database. The monolith is not meant to be implemented in detail; a skeleton that starts up is enough, so that in the following modules we have somewhere to add the services.

Folder structure

km0/
├── docker-compose.yml          # Local infrastructure: it will grow module by module
├── README.md
├── monolith/                   # The starting point (it will gradually be emptied)
│   ├── Dockerfile
│   ├── requirements.txt
│   ├── app.py                  # Application entry point
│   ├── catalog/                # One Python package per domain module
│   ├── orders/
│   ├── inventory/
│   ├── payments/
│   ├── delivery/
│   └── analytics/
├── services/                   # Empty for now. Module 2: catalog/, orders/ ...
├── infra/                      # Empty for now. Module 7: kubernetes/, ansible/
└── sql/
    └── 001_initial_schema.sql  # The monolith's tables

The services/ and infra/ folders are empty on purpose: they exist so that the evolution is visible. As each service is extracted, it will appear in services/ and disappear from monolith/.

A minimal docker-compose.yml

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: km0
      POSTGRES_USER: km0
      POSTGRES_PASSWORD: km0_dev        # for local development only
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./sql:/docker-entrypoint-initdb.d   # runs the .sql files when the DB is created
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U km0 -d km0"]
      interval: 5s
      timeout: 3s
      retries: 10

  monolith:
    build: ./monolith
    environment:
      DATABASE_URL: postgresql://km0:km0_dev@postgres:5432/km0
    ports:
      - "8000:8000"
    depends_on:
      postgres:
        condition: service_healthy     # waits until PostgreSQL responds

volumes:
  postgres_data:

An explanation of the parts that matter for the course:

  • services declares two containers: postgres and monolith. Docker Compose creates an internal network on which each service is reachable by its name: that is why the connection URL uses postgres as the host, rather than an IP (remember fallacy 5: the topology changes; names are stable, addresses are not).
  • healthcheck and depends_on ... condition: service_healthy are the first practical example of "do not assume the other node is ready": the monolith does not start until PostgreSQL responds to pg_isready. Without this, the monolith would start, try to connect, fail and die. This pattern will be repeated with every service we add.
  • volumes persists the PostgreSQL data across container restarts (the crash-recovery failure model: the process dies, the data on disk survives).
  • ./sql:/docker-entrypoint-initdb.d runs the initial schema the first time the database is created.

The monolith skeleton

monolith/app.py is deliberately minimal: it checks that it can talk to the database and exposes a health endpoint. It uses only the standard library plus psycopg, so as not to introduce dependencies that will be chosen in later modules.

import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer

import psycopg

DATABASE_URL = os.environ["DATABASE_URL"]


def count_products() -> int:
    """Minimal query to check that the database responds."""
    with psycopg.connect(DATABASE_URL) as conn:
        with conn.cursor() as cur:
            cur.execute("SELECT count(*) FROM products")
            return cur.fetchone()[0]


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/health":
            body = {"status": "ok", "products": count_products()}
            self._respond(200, body)
        else:
            self._respond(404, {"error": "not found"})

    def _respond(self, code: int, body: dict) -> None:
        data = json.dumps(body).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)


if __name__ == "__main__":
    print("Kilometre Zero (monolith) listening on :8000")
    HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()

monolith/requirements.txt contains a single line, psycopg[binary]>=3.1, and this is monolith/Dockerfile:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]

And sql/001_initial_schema.sql, with a minimal schema and the producers used in the course:

CREATE TABLE producers (
    id   SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    city TEXT NOT NULL
);

CREATE TABLE products (
    id          SERIAL PRIMARY KEY,
    producer_id INTEGER NOT NULL REFERENCES producers(id),
    name        TEXT NOT NULL,
    price       NUMERIC(10, 2) NOT NULL,   -- never float for money (fallacy 8)
    stock       INTEGER NOT NULL DEFAULT 0
);

INSERT INTO producers (name, city) VALUES
    ('La Vega Farm', 'Lleida'),
    ('Montblanc Dairy', 'Girona'),
    ('Roble Alto Winery', 'Tarragona');

INSERT INTO products (producer_id, name, price, stock) VALUES
    (1, 'Pink tomato', 3.20, 120),
    (1, 'Zucchini', 2.10, 80),
    (2, 'Aged sheep''s cheese', 14.50, 5),
    (2, 'Fresh cheese', 6.80, 30),
    (3, 'Crianza red wine', 9.90, 200);

To start the environment:

cd km0
docker compose up --build

And, in another terminal, check that the monolith responds:

curl http://localhost:8000/health

The response should be {"status": "ok", "products": 5}.

That sets up the starting point: exactly the monolith from lesson 01-01, now runnable in containers. In Module 2, services/catalog/ will appear along with a second entry in docker-compose.yml.

Common Mistakes and Tips

  • Splitting the monolith by technical layers instead of by domains. Creating a "database service", a "logic service" and an "API service" isolates nothing: every operation still goes through all three. The right boundaries are the business ones (catalog, orders...), which is where coupling is lowest.
  • Extracting services that share a database. If orders and inventory are deployed separately but read and write the same tables, they are not two services: they are a distributed monolith, with all the costs of distribution and none of its advantages.
  • Making every communication synchronous. It turns every flow into a chain of multiplied availabilities and added latencies. The question for each interaction should be "do I need the answer now?"; if not, it is an event.
  • Making every communication asynchronous. The opposite extreme fails too: checking the stock through events, waiting for the answer to arrive "eventually", makes it impossible to give the customer an immediate confirmation. Synchronous communication exists for a reason.
  • Choosing technologies before problems. "We're going to use Kafka and Cassandra" is not an architecture decision unless it responds to a symptom. Every piece of the target architecture is justified by one of the five symptoms; if you cannot name the symptom, remove the piece.
  • Leaving observability and security until the end. When there are six services and something fails, it is too late to start wondering how to correlate logs. Both are part of the first service you extract.
  • Tip: keep an architecture decision log (one ADR per decision: context, options, decision, consequences). A year from now, when someone asks "why Cassandra for orders?", the answer will be written down.

Exercises

Exercise 1: Identifying service boundaries

The team proposes adding two features: (a) customer ratings and reviews of products, and (b) per-campaign discount coupons, which are applied to the order total and have a maximum number of uses. For each one, decide whether it should be a new service or part of an existing one, state which data it would own and which communications (synchronous or asynchronous) it would need with the rest. Justify each decision with the principles from section 3.

Exercise 2: Choosing the type of communication

For each interaction, say whether it should be synchronous (RPC) or asynchronous (event), and reason about what would happen with the alternative:

  1. orders needs to know whether there is stock before confirming an order.
  2. catalog wants to show "only a few left" when the stock drops below 5.
  3. Anna's app wants to show the payment confirmation immediately after she presses "pay".
  4. analytics wants to count every sale for the campaign report.
  5. delivery needs to know the delivery address of every paid order in order to plan routes.

Exercise 3: Graceful degradation

For each of the following failures, design what must keep working at Kilometre Zero and what message or behaviour the user will see. State which design principle makes it possible:

  1. payments goes down (the external gateway is not responding).
  2. Redis (the catalog cache) goes down.
  3. The event bus (Kafka) goes down for 10 minutes.
  4. delivery goes down completely.

Solutions

Solution 1:

(a) Reviews: a good candidate for a new service (reviews). It has its own data (rating, comment, customer, product, date), an independent life cycle (moderation, producer replies), a different load pattern (many reads when viewing a product, few writes) and could be scaled separately. Communication: asynchronous with orders (it consumes OrderDelivered so that only purchased products can be reviewed) and asynchronous towards catalog (it publishes ReviewCreated so that the catalog keeps a denormalised average rating, with no synchronous calls when displaying the product page). The principle: each service owns its data; synchronous only when the answer is needed now (showing the average rating does not require querying reviews on every request).

(b) Coupons: this one is more debatable. The maximum number of uses requires a strongly consistent check at the moment the order is confirmed (coupon use number 101 cannot be allowed if the maximum is 100), which brings it close to the stock problem. Two reasonable options: include it in orders (which already handles the total and the confirmation) or create promotions as a service of its own with a synchronous reserve_use(coupon) call from orders, analogous to the stock reservation. A separate service is justified if campaigns are going to grow in complexity (rules, segmentation, a marketing team with its own deployment pace); if they are just simple coupons, putting them in orders avoids a network boundary in the most critical flow. The important thing is to recognise that the answer depends on the symptom you want to solve, not on a fixed rule.

Solution 2:

  1. Synchronous. The customer is waiting for the confirmation; without an immediate answer no decision can be made. With events, orders would have to wait "a while" for inventory to reply through another channel, and could not give the customer an answer within the same request.
  2. Asynchronous. catalog consumes StockUpdated and keeps a local indicator. With synchronous calls, every product view would make a call to inventory, multiplying the load and adding a dependency to the highest-volume flow (symptom 1).
  3. Synchronous for the orderspayments interaction (the customer is waiting), but with a short timeout and a degradation plan: if payments does not respond within 3 seconds, the order is left as "payment pending" and resolved later through events (PaymentConfirmed), with the customer being notified. It is a mixture: synchronous on the happy path, asynchronous as a safety net.
  4. Asynchronous. analytics consumes OrderPaid. A synchronous call to analytics from orders would couple the purchase flow to an analytical system that does not need to be available in order to sell (symptom 5).
  5. Asynchronous. delivery consumes OrderPaid, which includes the address. Planning routes is not immediate; and if delivery is down, the event waits on the bus and is processed later.

Solution 3:

  1. payments goes down. Catalog, basket, delivery tracking and analytics keep working. When confirming an order, after the timeout, the customer sees: "We couldn't process your payment just now; your order has been saved and we'll let you know as soon as it goes through" (order in the "payment pending" state; the stock is reserved with an expiry). Principle: failure isolation + timeouts + asynchrony as a safety net. Compare this with symptom 2, where this very failure brought the whole platform down.
  2. Redis goes down. catalog keeps working by reading from its PostgreSQL, more slowly and with less capacity; during a campaign it might be necessary to limit traffic. The user sees no error, just more latency. Principle: the cache is an optimisation, not a source of truth; the service must work without it.
  3. Kafka goes down for 10 minutes. Synchronous operations (buying, paying, reserving stock) keep working. Events pile up in each producing service (the outbox pattern, lesson 02-05) and are published when the bus comes back. Visible effects: the courier map freezes, the "only a few left" indicator lags, the campaign reports run 10 minutes behind. Principle: synchronous only when needed; asynchronous communication tolerates delays by definition.
  4. delivery goes down. People can buy and pay as normal. Tracking on the map shows "Tracking temporarily unavailable; your order is confirmed". Courier positions pile up on the bus and are processed on recovery. The OrderPaid events wait; routes are planned late. Principle: failure isolation and temporal decoupling through events.

Conclusion

This lesson has turned the foundations of the module into a plan. We have opened up the Kilometre Zero monolith (six modules, one database, a transaction that stretches as far as the external payment gateway, a Tuesday-and-Thursday deployment) and identified the five symptoms forcing its evolution: campaign spikes, the payments failure that brings everything down, teams treading on each other's toes when deploying, real-time courier telemetry and analytics choking production. Each symptom justifies one piece of the target architecture: six services that own their data, synchronous communication over gRPC where an immediate answer is needed and asynchronous communication through Kafka events for everything else, storage tailored to each workload (replicated PostgreSQL, Cassandra, Redis, object storage), batch and stream processing for analytics, and security, observability and Kubernetes deployment as cross-cutting foundations. The table in section 5 is the map linking each component to the lesson that develops it, and the km0/ project with its minimal docker-compose.yml is the starting point we will build on. None of this has been implemented yet: it is a roadmap, and the path will be gradual, extracting one service at a time. The first step is getting two processes to talk to each other reliably, and that is exactly what begins in Module 2, Communication in Distributed Systems, with the network protocols on which everything else rests.

Distributed Architectures Course

Module 1: Introduction to Distributed Systems

Module 2: Communication in Distributed Systems

Module 3: Consistency and Replication

Module 4: Distributed Storage

Module 5: Distributed Computing

Module 6: Security in Distributed Systems

Module 7: Monitoring and Maintenance

Module 8: Case Studies and Applications

© Copyright 2026. All rights reserved