Las cinco lecciones anteriores han dado los fundamentos: qué es un sistema distribuido, con qué modelos se describe, qué gana y qué pierde quien distribuye, qué falacias hay que desterrar y por qué el tiempo es un problema. Esta lección de cierre los pone a trabajar sobre el caso que nos acompañará el resto del curso. Vamos a examinar con detalle el monolito de Kilómetro Cero, los síntomas concretos que lo empujan a cambiar y la arquitectura objetivo que iremos construyendo módulo a módulo.

Es importante entender el carácter de esta lección: es un mapa, no el viaje. Aquí no se explica ninguna tecnología concreta; se decide qué piezas necesita la plataforma, por qué y en qué lección del curso se desarrolla cada una. También prepararemos el entorno de trabajo mínimo (la estructura de carpetas del proyecto km0/ y un docker-compose.yml con solo PostgreSQL y la aplicación Python) que será el punto de partida sobre el que se irán añadiendo servicios.

Contenido

  1. El monolito de Kilómetro Cero por dentro
  2. Los síntomas que fuerzan la evolución
  3. Principios de diseño para la transición
  4. La arquitectura objetivo
  5. Mapa de la arquitectura al curso
  6. Un camino gradual, no un "big bang"
  7. Preparación del entorno de trabajo: el proyecto km0/
  8. Errores comunes y consejos
  9. Ejercicios de diseño
  10. Conclusión

  1. El monolito de Kilómetro Cero por dentro

Recordemos el punto de partida (lección 01-01): una aplicación Python en un único proceso, una base de datos PostgreSQL y un único servidor. Mirémoslo ahora con más detalle, porque para decidir cómo partirlo hay que entender cómo está hecho.

Módulos internos

El código está organizado en seis módulos Python, que se corresponden con los dominios funcionales del negocio:

Módulo Responsabilidad Tablas principales Quién lo usa
catalogo Productos, productores, categorías, fotos, búsqueda productos, productores, categorias Clientes (muchas lecturas), productores (pocas escrituras)
pedidos Carrito, creación y ciclo de vida del pedido pedidos, lineas_pedido Clientes, atención al cliente
inventario Stock por producto y por productor, reservas stock, movimientos_stock pedidos, productores
pagos Cobros con la pasarela externa, reembolsos pagos, reembolsos pedidos
reparto Asignación de repartidores, rutas, posición en tiempo real repartos, repartidores, posiciones Repartidores (app móvil), clientes (seguimiento)
analitica Informes de ventas, productos más vendidos, previsión de demanda Consultas sobre todas las anteriores Dirección, productores

Cómo interactúan

Las interacciones entre módulos son llamadas a funciones Python, y las operaciones que tocan varios módulos se apoyan en una única transacción de base de datos. El flujo de confirmar un pedido es el ejemplo más claro:

sequenceDiagram
    participant C as Cliente (Ana)
    participant P as pedidos
    participant I as inventario
    participant PA as pagos
    participant DB as PostgreSQL
    C->>P: confirmar_pedido(carrito)
    P->>DB: BEGIN
    P->>I: reservar_stock(lineas)
    I->>DB: UPDATE stock ...
    P->>PA: cobrar(importe, tarjeta)
    PA->>PA: llamada a pasarela externa (HTTPS)
    PA->>DB: INSERT pago
    P->>DB: INSERT pedido
    P->>DB: COMMIT
    P-->>C: pedido confirmado

Fíjate en un detalle que será decisivo: la llamada a la pasarela de pagos externa ocurre dentro de la transacción de base de datos. Si la pasarela tarda 8 segundos, la transacción (y los bloqueos sobre las filas de stock) duran 8 segundos. Y si la pasarela falla, la transacción se deshace entera, lo que es correcto, pero mientras tanto ha estado bloqueando el stock para todos los demás clientes.

Despliegue

Un único artefacto (un paquete Python) que se instala en el servidor con un script. El despliegue implica parar el proceso, instalar y arrancar: unos 40 segundos de indisponibilidad total. Se despliega los martes y jueves por la noche, con todos los cambios de todos los equipos acumulados desde el despliegue anterior.

  1. Los síntomas que fuerzan la evolución

No hay una única razón para transformar Kilómetro Cero, sino cinco síntomas distintos, y cada uno de ellos empuja hacia una pieza diferente de la arquitectura objetivo. Conviene identificarlos con precisión porque cada síntoma justifica una decisión concreta, y las decisiones que no responden a ningún síntoma son complejidad gratuita.

Síntoma 1: los picos de campaña tumban todo

Ya lo vimos: la Semana de la Vendimia multiplicó por 15 el tráfico y el servidor cayó por completo. El análisis posterior mostró que el 92 % de las peticiones eran lecturas del catálogo (listados, búsquedas, fichas de producto con fotos). El resto de la plataforma cayó por compartir proceso y base de datos con el catálogo.

Qué pide: poder escalar el catálogo de forma independiente, servir las lecturas desde una caché y las fotos desde un almacenamiento aparte, y aislar los fallos para que una sobrecarga del catálogo no afecte a los pedidos ni al reparto.

Síntoma 2: un fallo de pagos que tumbó todo

Un martes, la pasarela de pagos externa empezó a responder con 30 segundos de retraso. Cada confirmación de pedido mantenía una transacción abierta (y bloqueos de stock) durante esos 30 segundos. En cuatro minutos, PostgreSQL agotó su límite de conexiones y toda la plataforma, incluidas las páginas del catálogo que no tienen nada que ver con pagos, dejó de responder. Un fallo de un proveedor externo se convirtió en una caída total.

Qué pide: que pagos sea un componente aislado con sus propios recursos, que su lentitud o su fallo se contenga (timeouts, degradación controlada), y que la confirmación de pedido no dependa de una transacción única que abarca sistemas externos.

Síntoma 3: los equipos se pisan al desplegar

Kilómetro Cero ya tiene cuatro equipos (catálogo y productores, pedidos y pagos, reparto, datos). Cada despliegue lleva los cambios de todos, así que un error en el módulo de reparto obliga a revertir también las mejoras del catálogo. El equipo de reparto quiere desplegar varias veces al día; el de pagos, sujeto a auditoría, quiere ciclos largos y controlados. El resultado es que nadie despliega con la frecuencia que necesita, y cada despliegue de martes es un acontecimiento tenso.

Qué pide: unidades de despliegue independientes por dominio, con sus propios ciclos, pruebas y responsables, y una plataforma de despliegue que permita hacerlo sin parada.

Síntoma 4: la telemetría de repartidores en tiempo real

Kilómetro Cero ha pasado de 12 a 140 repartidores, y cada uno envía su posición cada 5 segundos: 28 posiciones por segundo, 2,4 millones al día, que se insertan en la tabla posiciones de la misma base de datos que gestiona los pedidos. La tabla crece 800 MB al día y las inserciones compiten con las transacciones de los pedidos. Además, los clientes quieren ver el repartidor moverse en el mapa en tiempo real, lo que con el modelo petición-respuesta obliga a que la app pregunte cada pocos segundos, multiplicando la carga.

Qué pide: un canal de comunicación distinto para flujos de eventos de alta frecuencia (no una tabla relacional), comunicación asíncrona y bidireccional con las apps, y un almacenamiento adecuado para series temporales de gran volumen.

Síntoma 5: la analítica de ventas ahoga la producción

Cada noche, el módulo analitica ejecuta consultas pesadas sobre las tablas de pedidos y stock para generar informes. Las consultas tardan 3 horas y, durante ese tiempo, la base de datos va lenta para todos. Los productores piden informes más ricos (previsión de demanda por temporada, comparativas entre ciudades) que el equipo de datos no se atreve a implementar porque cada consulta nueva pone en riesgo la producción.

Qué pide: separar la carga analítica de la transaccional, con una copia de los datos en un sistema diseñado para el procesamiento masivo, alimentada por los eventos que producen los demás servicios.

  1. Principios de diseño para la transición

Antes de dibujar la arquitectura objetivo, fijemos los principios que se derivan del módulo y que guiarán todas las decisiones:

  1. Cada servicio es dueño de sus datos. Ningún servicio accede directamente a las tablas de otro. Si analitica necesita los pedidos, los recibe como eventos o a través de la API de pedidos. Es lo que hace posible el despliegue y el escalado independientes (síntomas 1 y 3), y lo que obliga a renunciar a la transacción única (que se sustituye por las técnicas del Módulo 3).
  2. Aislamiento de fallos. Un fallo o una sobrecarga en un servicio no debe propagarse a los demás (síntomas 1 y 2). Esto exige recursos separados, timeouts en toda llamada remota (lección 01-04) y degradación controlada: si reparto cae, se puede seguir comprando; si pagos cae, se puede seguir mirando el catálogo.
  3. Síncrono solo cuando la respuesta hace falta ahora. Comprobar el stock antes de confirmar un pedido necesita respuesta inmediata: síncrono. Avisar a analitica de que se ha creado un pedido no la necesita: asíncrono, mediante eventos. Cada llamada síncrona que se evita es una frontera de red menos en la cadena de disponibilidad (lección 01-03).
  4. Asumir las falacias como ciertas al revés. La red fallará, tendrá latencia, no será segura, la topología cambiará. Toda comunicación entre servicios lleva timeout, reintentos idempotentes, cifrado y descubrimiento dinámico.
  5. Observabilidad desde el principio. Con seis servicios, sin métricas, logs correlacionados y trazas distribuidas no hay forma de saber qué pasa. No es un añadido posterior: es parte del diseño.
  6. Evolución gradual. No se reescribe todo a la vez. Se extrae un servicio, se comprueba, se extrae el siguiente. El monolito sigue funcionando durante toda la transición.

  1. La arquitectura objetivo

Con los síntomas y los principios sobre la mesa, esta es la plataforma que el curso construirá:

flowchart TB
    subgraph Clientes
        WEB[Navegador web]
        APP[App móvil clientes]
        REP[App repartidores]
    end

    GW[Puerta de enlace / API Gateway<br/>autenticación, limitación de tasa]
    WEB --> GW
    APP --> GW
    REP -->|MQTT / WebSocket| RT[Servidor tiempo real]

    subgraph Servicios["Servicios (Kubernetes)"]
        CAT[catalogo]
        PED[pedidos]
        INV[inventario]
        PAG[pagos]
        REPA[reparto]
        ANA[analitica]
    end

    GW --> CAT
    GW --> PED
    GW --> REPA
    PED -->|gRPC síncrono| INV
    PED -->|gRPC síncrono| PAG
    RT --> REPA

    subgraph Bus["Bus de eventos (Kafka)"]
        T1[(pedidos.eventos)]
        T2[(reparto.posiciones)]
        T3[(inventario.eventos)]
    end
    PED -.->|PedidoCreado, PedidoPagado| T1
    INV -.->|StockActualizado| T3
    REPA -.->|PosicionActualizada| T2
    T1 -.-> ANA
    T2 -.-> ANA
    T3 -.-> CAT
    T1 -.-> REPA

    subgraph Datos["Almacenamiento por servicio"]
        PGC[(PostgreSQL<br/>catalogo)]
        RED[(Redis<br/>caché catálogo)]
        OBJ[(Almacén de objetos<br/>fotos)]
        CAS[(Cassandra<br/>pedidos)]
        PGI[(PostgreSQL replicado<br/>inventario)]
        PGP[(PostgreSQL<br/>pagos)]
        TS[(Cassandra<br/>posiciones)]
        DL[(Data lake + Spark/Flink<br/>analitica)]
    end
    CAT --> PGC
    CAT --> RED
    CAT --> OBJ
    PED --> CAS
    INV --> PGI
    PAG --> PGP
    PAG -->|HTTPS| EXT[Pasarela de pagos externa]
    REPA --> TS
    ANA --> DL

    subgraph Transversal["Transversal"]
        OBS[Observabilidad<br/>Prometheus, Grafana, OpenTelemetry]
        SEC[Seguridad<br/>OIDC, mTLS, secretos]
    end

Recorramos las decisiones, cada una vinculada a su síntoma:

Los seis servicios

Los límites de los servicios coinciden con los módulos del monolito, y no es casualidad: los módulos ya reflejaban los dominios del negocio y los equipos. Un buen monolito modular es la mejor preparación para una arquitectura de servicios. Cada servicio tiene su propio despliegue, su propio escalado y su propio almacenamiento (síntomas 1, 2 y 3).

Comunicación síncrona: gRPC

Para las interacciones que necesitan respuesta inmediata (pedidosinventario para reservar stock; pedidospagos para cobrar), se usará RPC con gRPC: llamadas tipadas, con contrato explícito, eficientes en serialización. Toda llamada lleva timeout y las operaciones con efectos son idempotentes (Módulo 2).

Comunicación asíncrona: eventos por Kafka

Para todo lo que no necesita respuesta inmediata, los servicios publican eventos en un bus (Kafka): PedidoCreado, PedidoPagado, StockActualizado, PosicionActualizada. Quien esté interesado se suscribe: analitica consume todo; catalogo consume StockActualizado para mostrar disponibilidad; reparto consume PedidoPagado para planificar la entrega. Esto desacopla a los servicios en el tiempo (si analitica está caída, los eventos esperan en el bus) y resuelve el síntoma 5 (los datos llegan a la analítica sin consultar la base de datos de producción) y parte del 4 (las posiciones son un flujo de eventos, no filas de una tabla transaccional).

Almacenamiento por servicio

Cada servicio elige el almacenamiento que mejor encaja con su carga:

Servicio Almacenamiento Por qué
catalogo PostgreSQL + Redis como caché + almacén de objetos para fotos Muchas lecturas repetidas (caché), datos relacionales sencillos, fotos grandes fuera de la base de datos (síntoma 1)
pedidos Cassandra Volumen alto de escrituras, consultas por cliente y por fecha, necesidad de escalar horizontalmente y de disponibilidad muy alta
inventario PostgreSQL replicado Necesita consistencia fuerte (no vender dos veces la última unidad) y transacciones; la replicación da disponibilidad
pagos PostgreSQL Transacciones, auditoría, volumen moderado
reparto Cassandra (posiciones, serie temporal) + PostgreSQL (asignaciones) 2,4 millones de posiciones al día; escrituras masivas por clave de repartidor y tiempo (síntoma 4)
analitica Data lake sobre almacén de objetos + procesamiento por lotes (Spark) y en flujo (Flink/Kafka Streams) Consultas masivas separadas de producción (síntoma 5)

La consecuencia de esta decisión es que la transacción única de confirmar pedido desaparece: reservar stock (PostgreSQL de inventario), cobrar (PostgreSQL de pagos) y crear el pedido (Cassandra) son tres operaciones en tres sistemas. Coordinarlas sin transacción global es el problema de las sagas (03-05).

Tiempo real para los repartidores

Las apps de los repartidores envían posiciones por MQTT (protocolo ligero para redes móviles) y los clientes reciben actualizaciones por WebSocket, a través de un servidor de tiempo real que publica en el bus y se alimenta de él (síntoma 4; lecciones 02-01 y 08-02).

Analítica: lotes y flujo

analitica deja de consultar producción. Los eventos del bus se guardan en un data lake, sobre el que corren trabajos por lotes (informes nocturnos, con Spark) y procesamiento en flujo (métricas en tiempo real de la campaña, con Flink o Kafka Streams), orquestados con un planificador (síntoma 5; Módulo 5).

Seguridad, observabilidad, despliegue

Una puerta de enlace autentica a los usuarios (OIDC) y limita el tráfico; los servicios se autentican entre sí con mTLS; los secretos se gestionan centralizadamente (Módulo 6). Métricas, logs y trazas distribuidas se recogen de todos los servicios (Módulo 7). Todo se despliega como contenedores en Kubernetes, en un proveedor de nube, con infraestructura definida como código (07-05, 08-03).

  1. Mapa de la arquitectura al curso

Esta tabla es la brújula del curso: cada componente de la arquitectura objetivo, y la lección en la que se construye o se explica.

Componente / decisión Módulo Lección
Sockets, TCP/UDP, HTTP, MQTT para repartidores 2 02-01
Llamadas síncronas entre servicios (RPC) 2 02-02
gRPC y serialización con contratos (pedidosinventario, pagos) 2 02-03
Bus de eventos Kafka, colas RabbitMQ 2 02-04
Idempotencia, outbox, colas de mensajes muertos, consumidores en competencia 2 02-05
Qué garantías da cada almacenamiento (modelos de consistencia) 3 03-01
Por qué inventario (consistencia) y pedidos (disponibilidad) eligen distinto: CAP y PACELC 3 03-02
Cómo se elige el líder de PostgreSQL replicado y cómo se coordina Kafka: consenso (Raft) 3 03-03
Replicación de PostgreSQL de inventario; detección de conflictos 3 03-04
Confirmar pedido sin transacción global: sagas 3 03-05
Cómo Cassandra reparte los pedidos y las posiciones: particionado y hashing consistente 4 04-01
Sistemas de archivos distribuidos para el data lake 4 04-02
Fotos de productos en almacén de objetos (S3/MinIO) 4 04-03
Cassandra para pedidos y para las posiciones de reparto 4 04-04
Redis como caché del catálogo 4 04-05
Modelos de computación distribuida para analitica 5 05-01
Informes por lotes: MapReduce y Hadoop 5 05-02
Previsión de demanda con Spark 5 05-03
Métricas de campaña en tiempo real: Flink / Kafka Streams 5 05-04
Orquestación de trabajos nocturnos con Airflow 5 05-05
Autenticación de clientes y roles (JWT) 6 06-01
Cifrado de datos de pago y de posiciones 6 06-02
Identidad de usuarios y productores (OAuth2/OIDC) 6 06-03
mTLS entre servicios, gestión de secretos 6 06-04
Puerta de enlace, limitación de tasa, auditoría 6 06-05
Métricas con Prometheus y Grafana 7 07-01
Logs centralizados y trazas con OpenTelemetry 7 07-02
Failover de PostgreSQL, checkpoints de Flink 7 07-03
Timeouts, reintentos, circuit breaker en pedidospagos 7 07-04
Despliegue en Kubernetes, Ansible 7 07-05
Pruebas e ingeniería del caos 7 07-06
Microservicios: límites, contratos, organización 8 08-01
WebSockets y MQTT para seguimiento en tiempo real 8 08-02
Despliegue en AWS/GCP 8 08-03
Funciones serverless para el redimensionado de fotos; edge para los mercados 8 08-04
Kilómetro Cero de extremo a extremo 8 08-05

  1. Un camino gradual, no un "big bang"

La arquitectura objetivo es el destino, no el primer paso. Reescribir todo de golpe es la forma más segura de fracasar: durante meses no habría nada que desplegar, y al final habría que migrar todo a la vez. En su lugar, Kilómetro Cero seguirá el patrón conocido como estrangulamiento (strangler fig): se extrae un servicio del monolito, se redirige el tráfico hacia él y el monolito pierde esa responsabilidad; se repite con el siguiente. En cada paso, el sistema funciona y se puede desplegar.

Fase Qué se extrae Síntoma que resuelve Módulos del curso
1 catalogo (con Redis y almacén de fotos) 1: picos de campaña 2, 4
2 pagos (aislado, con timeouts y circuit breaker) 2: fallo de pagos que tumba todo 2, 7
3 reparto con el bus de eventos y el canal en tiempo real 4: telemetría 2, 4, 8
4 pedidos e inventario (con sagas y replicación) 3: despliegues independientes 3, 4
5 analitica sobre el bus y el data lake 5: analítica 5
Transversal Seguridad, observabilidad, Kubernetes Todos 6, 7

Se empieza por el catálogo porque es el que causa el síntoma más grave, el que tiene menos dependencias (solo lecturas) y el que menos riesgo tiene: si el nuevo servicio falla, se vuelve a apuntar al monolito.

  1. Preparación del entorno de trabajo: el proyecto km0/

Para seguir el curso de forma práctica, crearemos un proyecto que iremos ampliando. En este módulo solo contiene el punto de partida: el monolito y su base de datos. No se pretende que el monolito esté implementado en detalle; basta con un esqueleto que arranque, para que en los módulos siguientes tengamos dónde añadir los servicios.

Estructura de carpetas

km0/
├── docker-compose.yml          # Infraestructura local: crecerá módulo a módulo
├── README.md
├── monolito/                   # El punto de partida (se irá vaciando)
│   ├── Dockerfile
│   ├── requirements.txt
│   ├── app.py                  # Punto de entrada de la aplicación
│   ├── catalogo/               # Un paquete Python por módulo de dominio
│   ├── pedidos/
│   ├── inventario/
│   ├── pagos/
│   ├── reparto/
│   └── analitica/
├── servicios/                  # Vacío por ahora. Módulo 2: catalogo/, pedidos/ ...
├── infra/                      # Vacío por ahora. Módulo 7: kubernetes/, ansible/
└── sql/
    └── 001_esquema_inicial.sql # Tablas del monolito

Las carpetas servicios/ e infra/ están vacías a propósito: existen para que la evolución sea visible. A medida que se extraiga cada servicio, aparecerá en servicios/ y desaparecerá de monolito/.

docker-compose.yml mínimo

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: km0
      POSTGRES_USER: km0
      POSTGRES_PASSWORD: km0_dev        # solo para desarrollo local
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./sql:/docker-entrypoint-initdb.d   # ejecuta los .sql al crear la BD
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U km0 -d km0"]
      interval: 5s
      timeout: 3s
      retries: 10

  monolito:
    build: ./monolito
    environment:
      DATABASE_URL: postgresql://km0:km0_dev@postgres:5432/km0
    ports:
      - "8000:8000"
    depends_on:
      postgres:
        condition: service_healthy     # espera a que PostgreSQL responda

volumes:
  postgres_data:

Explicación de las partes relevantes para el curso:

  • services declara dos contenedores: postgres y monolito. Docker Compose crea una red interna en la que cada servicio es accesible por su nombre: por eso la URL de conexión usa postgres como host, y no una IP (recuerda la falacia 5: la topología cambia; los nombres son estables, las direcciones no).
  • healthcheck y depends_on ... condition: service_healthy son el primer ejemplo práctico de "no asumas que el otro nodo está listo": el monolito no arranca hasta que PostgreSQL responde a pg_isready. Sin esto, el monolito arrancaría, intentaría conectar, fallaría y moriría. Este patrón se repetirá con cada servicio que añadamos.
  • volumes persiste los datos de PostgreSQL entre reinicios del contenedor (modelo de fallo crash-recovery: el proceso muere, los datos en disco sobreviven).
  • ./sql:/docker-entrypoint-initdb.d ejecuta el esquema inicial la primera vez que se crea la base de datos.

Esqueleto del monolito

monolito/app.py es deliberadamente mínimo: comprueba que puede hablar con la base de datos y expone un punto de salud. Usa solo la biblioteca estándar más psycopg para no introducir dependencias que se elegirán en módulos posteriores.

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

import psycopg

DATABASE_URL = os.environ["DATABASE_URL"]


def contar_productos() -> int:
    """Consulta mínima para comprobar que la base de datos responde."""
    with psycopg.connect(DATABASE_URL) as conn:
        with conn.cursor() as cur:
            cur.execute("SELECT count(*) FROM productos")
            return cur.fetchone()[0]


class Manejador(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/salud":
            cuerpo = {"estado": "ok", "productos": contar_productos()}
            self._responder(200, cuerpo)
        else:
            self._responder(404, {"error": "no encontrado"})

    def _responder(self, codigo: int, cuerpo: dict) -> None:
        datos = json.dumps(cuerpo).encode()
        self.send_response(codigo)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(datos)))
        self.end_headers()
        self.wfile.write(datos)


if __name__ == "__main__":
    print("Kilómetro Cero (monolito) escuchando en :8000")
    HTTPServer(("0.0.0.0", 8000), Manejador).serve_forever()

monolito/requirements.txt contiene una única línea, psycopg[binary]>=3.1, y monolito/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"]

Y sql/001_esquema_inicial.sql con un esquema mínimo y los productores del curso:

CREATE TABLE productores (
    id     SERIAL PRIMARY KEY,
    nombre TEXT NOT NULL,
    ciudad TEXT NOT NULL
);

CREATE TABLE productos (
    id           SERIAL PRIMARY KEY,
    productor_id INTEGER NOT NULL REFERENCES productores(id),
    nombre       TEXT NOT NULL,
    precio       NUMERIC(10, 2) NOT NULL,   -- nunca float para dinero (falacia 8)
    stock        INTEGER NOT NULL DEFAULT 0
);

INSERT INTO productores (nombre, ciudad) VALUES
    ('Huerta La Vega', 'Lleida'),
    ('Quesería Montblanc', 'Girona'),
    ('Bodega Roble Alto', 'Tarragona');

INSERT INTO productos (productor_id, nombre, precio, stock) VALUES
    (1, 'Tomate rosa', 3.20, 120),
    (1, 'Calabacín', 2.10, 80),
    (2, 'Queso curado de oveja', 14.50, 5),
    (2, 'Queso fresco', 6.80, 30),
    (3, 'Vino tinto crianza', 9.90, 200);

Para arrancar el entorno:

cd km0
docker compose up --build

Y, en otra terminal, comprobar que el monolito responde:

curl http://localhost:8000/salud

La respuesta debe ser {"estado": "ok", "productos": 5}.

Con esto queda montado el punto de partida: exactamente el monolito de la lección 01-01, ahora ejecutable en contenedores. En el Módulo 2 aparecerá servicios/catalogo/ y una segunda entrada en docker-compose.yml.

Errores Comunes y Consejos

  • Partir el monolito por capas técnicas en lugar de por dominios. Crear un "servicio de base de datos", un "servicio de lógica" y un "servicio de API" no aísla nada: cada operación sigue atravesando los tres. Los límites correctos son los de negocio (catalogo, pedidos...), que es donde el acoplamiento es menor.
  • Extraer servicios que comparten base de datos. Si pedidos e inventario se despliegan por separado pero leen y escriben las mismas tablas, no son dos servicios: son un monolito distribuido, con todos los costes de la distribución y ninguna de sus ventajas.
  • Hacer todas las comunicaciones síncronas. Convierte cada flujo en una cadena de disponibilidades multiplicadas y de latencias sumadas. La pregunta para cada interacción debe ser "¿necesito la respuesta ahora?"; si no, es un evento.
  • Hacer todas las comunicaciones asíncronas. El extremo opuesto también falla: comprobar el stock por eventos, esperando a que "eventualmente" llegue la respuesta, hace imposible dar una confirmación inmediata al cliente. Lo síncrono existe por una razón.
  • Elegir tecnologías antes que problemas. "Vamos a usar Kafka y Cassandra" no es una decisión de arquitectura si no responde a un síntoma. Cada pieza de la arquitectura objetivo está justificada por uno de los cinco síntomas; si no puedes nombrar el síntoma, quita la pieza.
  • Dejar la observabilidad y la seguridad para el final. Cuando hay seis servicios y algo falla, ya es tarde para preguntarse cómo correlacionar logs. Ambas son parte del primer servicio que se extrae.
  • Consejo: mantén un documento de decisiones de arquitectura (un ADR por decisión: contexto, opciones, decisión, consecuencias). Dentro de un año, cuando alguien pregunte "¿por qué Cassandra para pedidos?", la respuesta estará escrita.

Ejercicios

Ejercicio 1: Identificar límites de servicios

El equipo propone añadir dos funcionalidades: (a) valoraciones y comentarios de los clientes sobre los productos, y (b) cupones de descuento por campaña, que se aplican al importe del pedido y tienen un número máximo de usos. Para cada una, decide si debe ser un servicio nuevo o parte de uno existente, indica de qué datos sería dueña y qué comunicaciones (síncronas o asíncronas) necesitaría con el resto. Justifica cada decisión con los principios del apartado 3.

Ejercicio 2: Elegir el tipo de comunicación

Para cada interacción, indica si debe ser síncrona (RPC) o asíncrona (evento), y razona qué pasaría con la alternativa:

  1. pedidos necesita saber si hay stock antes de confirmar un pedido.
  2. catalogo quiere mostrar "quedan pocas unidades" cuando el stock baja de 5.
  3. La app de Ana quiere mostrar la confirmación del pago inmediatamente después de pulsar "pagar".
  4. analitica quiere contabilizar cada venta para el informe de campaña.
  5. reparto necesita conocer la dirección de entrega de cada pedido pagado para planificar rutas.

Ejercicio 3: Degradación controlada

Diseña, para cada uno de los siguientes fallos, qué debe seguir funcionando en Kilómetro Cero y qué mensaje o comportamiento verá el usuario. Indica qué principio de diseño lo hace posible:

  1. Cae pagos (la pasarela externa no responde).
  2. Cae Redis (la caché del catálogo).
  3. Cae el bus de eventos (Kafka) durante 10 minutos.
  4. Cae reparto por completo.

Soluciones

Solución 1:

(a) Valoraciones: es un buen candidato a servicio nuevo (valoraciones). Tiene datos propios (valoración, comentario, cliente, producto, fecha), un ciclo de vida independiente (moderación, respuestas del productor), un patrón de carga distinto (muchas lecturas al ver un producto, pocas escrituras) y podría escalarse por separado. Comunicación: asíncrona con pedidos (consume PedidoEntregado para permitir valorar solo productos comprados) y asíncrona hacia catalogo (publica ValoracionCreada para que el catálogo mantenga la nota media desnormalizada, sin llamadas síncronas al mostrar la ficha). El principio: cada servicio dueño de sus datos; síncrono solo cuando la respuesta hace falta ahora (mostrar la nota media no necesita consultar valoraciones en cada petición).

(b) Cupones: es más discutible. El número máximo de usos exige una comprobación con consistencia fuerte en el momento de confirmar el pedido (no se puede aplicar el cupón 101 si el máximo es 100), lo que lo acerca al problema del stock. Dos opciones razonables: incluirlo en pedidos (que ya gestiona el importe y la confirmación) o crear promociones como servicio propio con una llamada síncrona reservar_uso(cupon) desde pedidos, análoga a la reserva de stock. Un servicio propio se justifica si las campañas van a crecer en complejidad (reglas, segmentación, equipo de marketing con su propio ritmo de despliegue); si solo son cupones simples, meterlo en pedidos evita una frontera de red en el flujo más crítico. Lo importante es reconocer que la respuesta depende del síntoma que se quiera resolver, no de una regla fija.

Solución 2:

  1. Síncrona. El cliente espera la confirmación; sin respuesta inmediata no se puede decidir. Con eventos, pedidos tendría que esperar "un rato" a que inventario respondiera por otro canal, y no podría dar una respuesta al cliente en la misma petición.
  2. Asíncrona. catalogo consume StockActualizado y mantiene un indicador local. Con llamadas síncronas, cada visualización de producto haría una llamada a inventario, multiplicando la carga y añadiendo una dependencia en el flujo más masivo (síntoma 1).
  3. Síncrona en la interacción pedidospagos (el cliente espera), pero con timeout corto y un plan de degradación: si pagos no responde en 3 segundos, el pedido queda en "pago pendiente" y se resuelve después mediante eventos (PagoConfirmado), avisando a la clienta. Es una mezcla: síncrona en el camino feliz, asíncrona como red de seguridad.
  4. Asíncrona. analitica consume PedidoPagado. Una llamada síncrona a analitica desde pedidos acoplaría el flujo de compra a un sistema analítico que no necesita estar disponible para vender (síntoma 5).
  5. Asíncrona. reparto consume PedidoPagado, que incluye la dirección. Planificar rutas no es inmediato; y si reparto está caído, el evento espera en el bus y se procesa después.

Solución 3:

  1. Cae pagos. Catálogo, carrito, seguimiento de reparto y analítica siguen funcionando. Al confirmar un pedido, tras el timeout, el cliente ve: "No hemos podido procesar el pago ahora mismo; tu pedido queda guardado y te avisaremos en cuanto se complete" (pedido en estado "pago pendiente"; el stock se reserva con caducidad). Principio: aislamiento de fallos + timeouts + asincronía como red de seguridad. Compárese con el síntoma 2, donde este mismo fallo tumbaba toda la plataforma.
  2. Cae Redis. catalogo sigue funcionando leyendo de su PostgreSQL, más lento y con menos capacidad; durante una campaña podría ser necesario limitar el tráfico. El usuario no ve error, solo más latencia. Principio: la caché es una optimización, no una fuente de verdad; el servicio debe funcionar sin ella.
  3. Cae Kafka 10 minutos. Las operaciones síncronas (comprar, pagar, reservar stock) siguen funcionando. Los eventos se acumulan en cada servicio productor (patrón outbox, lección 02-05) y se publican cuando el bus vuelve. Efectos visibles: el mapa de repartidores se congela, el indicador "quedan pocas unidades" se retrasa, los informes de campaña van 10 minutos por detrás. Principio: síncrono solo cuando hace falta; lo asíncrono tolera retrasos por definición.
  4. Cae reparto. Se puede comprar y pagar con normalidad. El seguimiento en el mapa muestra "Seguimiento no disponible temporalmente; tu pedido está confirmado". Las posiciones de los repartidores se acumulan en el bus y se procesan al recuperarse. Los eventos PedidoPagado esperan; las rutas se planifican con retraso. Principio: aislamiento de fallos y desacoplamiento temporal mediante eventos.

Conclusión

Esta lección ha convertido los fundamentos del módulo en un plan. Hemos abierto el monolito de Kilómetro Cero —seis módulos, una base de datos, una transacción que abarca hasta la pasarela de pagos externa, un despliegue de martes y jueves— y hemos identificado los cinco síntomas que fuerzan su evolución: los picos de campaña, el fallo de pagos que tumba todo, los equipos que se pisan al desplegar, la telemetría de repartidores en tiempo real y la analítica que ahoga la producción. Cada síntoma justifica una pieza de la arquitectura objetivo: seis servicios dueños de sus datos, comunicación síncrona por gRPC donde hace falta respuesta inmediata y asíncrona por eventos en Kafka en todo lo demás, almacenamiento adaptado a cada carga (PostgreSQL replicado, Cassandra, Redis, almacén de objetos), procesamiento por lotes y en flujo para la analítica, y seguridad, observabilidad y despliegue en Kubernetes como cimientos transversales. La tabla del apartado 5 es el mapa que enlaza cada componente con la lección que lo desarrolla, y el proyecto km0/ con su docker-compose.yml mínimo es el punto de partida sobre el que iremos construyendo. Nada de esto se ha implementado todavía: es un roadmap, y el camino será gradual, extrayendo un servicio cada vez. El primer paso es hacer que dos procesos hablen entre sí de forma fiable, y eso es exactamente lo que empieza en el Módulo 2, Comunicación en Sistemas Distribuidos, con los protocolos de red sobre los que se apoya todo lo demás.

Curso de Arquitecturas Distribuidas

Módulo 1: Introducción a los Sistemas Distribuidos

Módulo 2: Comunicación en Sistemas Distribuidos

Módulo 3: Consistencia y Replicación

Módulo 4: Almacenamiento Distribuido

Módulo 5: Computación Distribuida

Módulo 6: Seguridad en Sistemas Distribuidos

Módulo 7: Monitoreo y Mantenimiento

Módulo 8: Casos de Estudio y Aplicaciones

© Copyright 2026. Todos los derechos reservados