La lección anterior cerró el Módulo 7 con una pregunta pendiente desde el Módulo 1: cuando el monolito Python+PostgreSQL se partió en catalogo, pedidos, inventario, pagos, reparto y analitica, ¿qué hizo que el resultado fuera un sistema y no una colección de seis programas que se llaman por red? Siete módulos han construido las piezas (gRPC y Kafka, sagas y réplicas, Cassandra y Redis, Spark y Flink, Keycloak y Vault, Prometheus y Kubernetes), pero ninguno ha explicado el criterio con el que se decidió dónde cortar, qué datos posee cada servicio, por qué catalogo no consulta la base de datos de inventario aunque técnicamente podría, ni qué se paga por cada una de esas decisiones. Esta lección mira a Kilómetro Cero desde arriba, como arquitectura de microservicios: qué son (y qué no), cómo se trazan las fronteras con un Domain-Driven Design ligero, cómo se resuelven las consultas que cruzan servicios cuando cada uno es dueño de sus datos, cómo se elige entre comunicación síncrona y asíncrona, cómo se migra desde un monolito sin detener el negocio, cómo se organizan los equipos y, sobre todo, cuándo no conviene hacerlo. Termina con el formato que deja escritas esas decisiones: el ADR. El tiempo real (08-02), la nube (08-03) y serverless (08-04) quedan para las lecciones siguientes.
Contenido
- Qué son los microservicios y qué no
- Las cinco características que los definen
- Cómo cortar las fronteras: Domain-Driven Design ligero
- Errores de corte
- Datos: una base de datos por servicio y sus consecuencias
- CQRS: el panel de pedidos de un productor y la vista
productos_vista - Comunicación: síncrona, asíncrona, APIs, gateway, BFF y mesh
- Patrones de migración: evaluación del plan de estrangulamiento
- Organización: equipos, Conway y plataforma interna
- Cuándo no usar microservicios y el retorno al monolito modular
- Registrar las decisiones: el ADR-001
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Qué son los microservicios y qué no
En 01-02 se presentó "servicios/microservicios" como un modelo de arquitectura más, junto a cliente-servidor, capas y P2P, y se remitió aquí. La definición operativa es esta: una arquitectura de microservicios descompone una aplicación en servicios pequeños, cada uno alineado con una capacidad de negocio, que se despliegan de forma independiente y se comunican por red mediante contratos explícitos. Lo que la distingue no es el tamaño ni la tecnología, sino tres propiedades que se refuerzan entre sí: independencia de despliegue, propiedad de los datos y fronteras trazadas por el negocio. Conviene situarla frente a sus dos vecinas, con las que suele confundirse.
| Aspecto | Monolito modular | SOA clásica | Microservicios |
|---|---|---|---|
| Unidad de despliegue | Una (todo el proceso) | Pocas, grandes, compartiendo un bus corporativo (ESB) | Muchas, pequeñas, una por capacidad |
| Fronteras | Paquetes/módulos dentro del mismo código; se cruzan con llamadas a función | Servicios de grano grueso, a menudo por sistema (CRM, ERP) | Por capacidad de negocio (bounded context) |
| Datos | Una base de datos compartida | Bases compartidas entre servicios; el ESB transforma | Una base de datos por servicio; nada compartido |
| Comunicación | Llamadas en proceso | Bus centralizado con lógica de orquestación y transformación | "Tuberías tontas, extremos listos": HTTP/gRPC, eventos; la lógica en los servicios |
| Consistencia | Transacciones ACID locales | Transacciones distribuidas (2PC, 03-05) | Consistencia eventual, sagas |
| Equipos | Uno o varios sobre el mismo código | Por sistema o por capa | Un equipo dueño de cada servicio, de extremo a extremo |
| Escalado | Todo o nada (copias del proceso) | Por servicio, pero con el ESB como cuello | Por servicio, independiente |
| Fallo | Un error tumba el proceso entero | El ESB es un punto único | Aislado por servicio (si está bien hecho) |
| Cuándo encaja | Equipos pequeños, dominio aún inestable, latencia crítica | Integración de sistemas heredados heterogéneos | Varios equipos, dominios claros, necesidad de escalado y despliegue diferenciados |
Dos aclaraciones importantes. La primera: el monolito modular no es el enemigo. El monolito de Kilómetro Cero en 01-06 ya tenía seis paquetes por dominio, y esa modularidad fue lo que hizo posible la extracción; un monolito con módulos bien separados y una sola base de datos es una arquitectura legítima y, para muchas empresas, la mejor. La segunda: la SOA de los años 2000 fracasó no por la idea de servicios sino por el ESB (Enterprise Service Bus), que concentraba lógica, transformaciones y orquestación en un componente central que acababa siendo un nuevo monolito, propiedad de un equipo de integración que todos esperaban. Kafka, en Kilómetro Cero, es deliberadamente lo contrario: un log que no transforma ni decide nada (02-04); toda la inteligencia está en los productores y consumidores.
- Las cinco características que los definen
Las cinco propiedades siguientes son el examen que cada servicio de Kilómetro Cero tiene que aprobar. Cuando una falla, el sistema se acerca al monolito distribuido, la peor combinación posible: la complejidad de la red sin la independencia que la justificaba.
| Característica | Qué significa | Cómo se cumple en Kilómetro Cero | Cómo se rompe |
|---|---|---|---|
| Un servicio = una capacidad de negocio | El servicio hace algo que el negocio reconoce con su nombre: "reservar stock", "cobrar", "asignar reparto" | inventario es lo que un operador llama "el stock"; reparto es lo que Jordi Sala llama "la flota" |
Servicios por capa técnica (servicio-base-de-datos, servicio-validaciones) |
| Dueño de sus datos | Solo el servicio lee y escribe su almacenamiento; los demás pasan por su API o por sus eventos | km0_inventario solo lo toca inventario; catalogo sabe el stock por stock.actualizado (04-05) |
Un JOIN de analitica contra las tablas de pedidos; una tabla productos compartida |
| Despliegue independiente | Se puede publicar una versión nueva sin coordinar con nadie ni desplegar a nadie más | pedidos 1.15.0 salió en canary (07-05) sin que inventario cambiara |
Un cambio de inventario que obliga a redesplegar pedidos el mismo día |
| Equipo dueño | Un equipo decide, construye, despliega y opera el servicio (you build it, you run it) | El equipo de reparto lleva reparto y está de guardia por él (07-03) |
Un "equipo de backend" que toca los seis servicios |
| Fallos aislados | Un servicio caído o lento degrada su función, no la plataforma | pagos lento abre el circuito (07-04) y el pedido queda "pago pendiente"; el catálogo sigue |
Llamadas síncronas encadenadas sin timeout: el síntoma 2 de 01-06 |
Nótese que ninguna característica habla de tamaño en líneas de código. "Micro" es una desafortunada elección de nombre: el tamaño correcto es el que permite cumplir las cinco propiedades con un equipo de tamaño razonable (la regla informal de "un equipo que se alimenta con dos pizzas"). pedidos es probablemente el servicio más grande de Kilómetro Cero, con su orquestador de sagas, su outbox y su repositorio de Cassandra, y está bien así: partirlo en pedidos-creacion y pedidos-consulta no añadiría independencia, solo llamadas de red.
- Cómo cortar las fronteras: Domain-Driven Design ligero
La pregunta más difícil de una arquitectura de microservicios no es técnica: es dónde cortar. Cortar mal produce servicios que cambian siempre a la vez, que se llaman en cadena para cualquier operación y que comparten datos por la puerta de atrás. La herramienta conceptual más útil es el Domain-Driven Design (DDD), del que aquí tomamos solo tres ideas, suficientes para trazar fronteras sin adoptar toda su metodología.
Lenguaje ubicuo
Cada dominio tiene un vocabulario que el negocio y el código deben compartir palabra por palabra. Un producto en el catálogo tiene nombre, descripción, fotos y precio; para el inventario es una referencia con unidades disponibles y reservadas en un mercado; para el reparto es un bulto con peso y necesidad de frío. Es el mismo queso-curado, pero con tres significados distintos, y forzar una única clase Producto con los campos de los tres es lo que produce las tablas con 80 columnas del monolito. El lenguaje ubicuo es la señal para detectar fronteras: donde una misma palabra cambia de significado, hay un límite de contexto.
Bounded contexts
Un bounded context (contexto delimitado) es el ámbito en el que un modelo y su lenguaje son válidos y coherentes. Dentro, un término significa una sola cosa; fuera, puede significar otra. Un microservicio bien cortado coincide con un bounded context (o, a veces, con un conjunto pequeño de ellos que un equipo posee juntos). Los seis contextos de Kilómetro Cero, con el término que da nombre a su modelo central:
| Contexto | Concepto central (agregado) | Términos propios | Lo que no le pertenece |
|---|---|---|---|
catalogo |
Producto publicado | ficha, foto, precio de venta, productor, categoría, "disponible" (indicador, no cantidad) | La cantidad exacta en stock; el precio pagado en un pedido concreto |
pedidos |
Pedido | línea, estado (creado, pagado, rechazado, entregado), importe, saga |
Qué hay en stock; cómo se cobra; dónde está la furgoneta |
inventario |
Referencia por mercado | unidades disponibles/reservadas, reserva con caducidad, umbral de alerta | La descripción o la foto; el precio |
pagos |
Cobro | pasarela, autorización, reembolso, Idempotency-Key |
Las líneas del pedido (solo el importe y la referencia) |
reparto |
Ruta y posición | repartidor, bulto, franja horaria, posición, asignación | El contenido del pedido más allá de peso y frío |
analitica |
Hecho de venta | ventas por día/mercado/productor, previsión, alerta de stock (lectura) | Nada transaccional: solo lee eventos |
Mapa de contextos
El mapa de contextos dibuja las relaciones entre bounded contexts y, para cada una, quién manda sobre el contrato. Las relaciones habituales son: cliente-proveedor (el consumidor negocia el contrato con el proveedor: pedidos pide a inventario un ReservarStock), conformista (el consumidor acepta el modelo del proveedor tal cual: analitica consume los eventos como vienen), capa anticorrupción (el consumidor traduce el modelo ajeno al suyo para no contaminarse: pagos frente a la pasarela externa) y lenguaje publicado (un contrato explícito y versionado que todos conocen: los eventos de pedidos.eventos con su envoltura id_evento/tipo/version/fecha_ms/origen/datos de 02-04).
flowchart LR
subgraph Web[Clientes]
NAV[Navegador de Ana]
APP[App repartidores]
PAN[Panel productores]
end
NAV --> GW[Kong<br/>06-05]
APP --> GW
PAN --> GW
GW --> CAT[catalogo<br/>Producto publicado]
GW --> PED[pedidos<br/>Pedido]
GW --> REP[reparto<br/>Ruta y posición]
PED -- "gRPC ReservarStock<br/>cliente-proveedor" --> INV[inventario<br/>Referencia por mercado]
PED -- "gRPC Cobrar<br/>cliente-proveedor" --> PAG[pagos<br/>Cobro]
PAG -- "ACL" --> PAS[(Pasarela externa)]
PED -. "pedido.creado / pago.confirmado<br/>lenguaje publicado" .-> K[(Kafka<br/>pedidos.eventos)]
INV -. "stock.actualizado / stock.reservado" .-> K
K -. conformista .-> CAT
K -. conformista .-> REP
K -. conformista .-> ANA[analitica<br/>Hecho de venta]
REP -. "reparto.posiciones" .-> K2[(Kafka<br/>reparto.*)]
K2 -.-> ANA
Las flechas continuas son llamadas síncronas y dibujan las dependencias en tiempo de ejecución: si inventario cae, pedidos no puede confirmar (pero sí aceptar, como se vio en 03-05). Las discontinuas son eventos y no crean dependencia en tiempo de ejecución: catalogo sigue funcionando con la última vista que tenga aunque Kafka esté caído. Un mapa con muchas flechas continuas es la primera señal de un corte discutible.
Heurísticas para decidir dónde cortar
Cuando el lenguaje no basta para decidir, cuatro heurísticas ayudan, y conviene aplicarlas todas porque a veces se contradicen:
- Cohesión: lo que cambia junto debe vivir junto. La reserva de stock y la liberación de la reserva son la misma capacidad; separarlas en dos servicios obligaría a coordinar cada cambio de reglas en dos sitios.
- Tasa de cambio: lo que cambia a ritmos distintos conviene separarlo.
pagoscambia poco y con auditoría;catalogocambia varias veces al día en campaña. Juntarlos impone al catálogo el ritmo de pagos (síntoma 3 de 01-06). - Datos que cambian juntos en la misma transacción: si dos entidades necesitan actualizarse atómicamente con frecuencia, separarlas obliga a una saga por operación. Las unidades disponibles y las reservadas de una referencia se actualizan siempre juntas: mismo servicio, misma fila. El pedido y el stock se actualizan juntos solo al confirmar: aquí sí compensa la saga porque el resto del tiempo son independientes.
- Equipos: una frontera de servicio que atraviesa un equipo genera dos servicios que se despliegan siempre a la vez; una que agrupa dos equipos genera peleas por el mismo código. La frontera debe coincidir con la propiedad (apartado 9).
- Errores de corte
Los tres errores más frecuentes tienen nombre propio, y merece la pena reconocerlos porque los tres parecen "más microservicios" y son lo contrario.
Servicios anémicos o servicios-entidad. Cortar por tabla: servicio-productos, servicio-clientes, servicio-pedidos, cada uno un CRUD sin lógica. Toda operación de negocio ("confirmar pedido") atraviesa cuatro o cinco de ellos en cadena síncrona, y la lógica acaba en un "servicio orquestador" que es el monolito de siempre con latencia de red. La prueba: si el servicio solo tiene endpoints GET/POST/PUT/DELETE sobre un recurso y ningún verbo de negocio (reservar, cobrar, asignar), es una tabla con API, no una capacidad.
Nanoservicios. Cortar tan fino que el coste de la comunicación supera al de la función: un servicio para calcular el IVA, otro para formatear direcciones. Cada uno necesita despliegue, monitorización, contrato, guardia. Kilómetro Cero decidió en 01-06 que pedidos incluye el cálculo del importe y no un servicio-precios, y en el ejercicio 1 de aquella lección que los cupones simples viven en pedidos, no en un servicio-promociones.
Monolito distribuido. El resultado de romper cualquiera de las cinco características del apartado 2: servicios que comparten base de datos, que deben desplegarse juntos porque comparten un modelo de datos o una librería interna con lógica de negocio, o que se llaman en cadena síncrona para todo. Señales medibles: cada release toca más de tres repositorios; un cambio de esquema de una tabla exige coordinar a dos equipos; la traza de una petición sencilla (07-02) muestra más de cinco saltos síncronos.
- Datos: una base de datos por servicio y sus consecuencias
El principio "cada servicio es dueño de sus datos" (01-06, principio 1) es el que más cuesta aceptar porque renuncia a la herramienta más potente del monolito: el JOIN. En el monolito, el panel de la Quesería Montblanc se resolvía con una consulta:
-- En el monolito: una sola consulta sobre una sola base de datos
SELECT p.id, p.fecha, p.estado, l.producto, l.unidades, pr.nombre,
r.repartidor, r.entrega_prevista
FROM pedidos p
JOIN lineas l ON l.pedido_id = p.id
JOIN productos pr ON pr.slug = l.producto
LEFT JOIN repartos r ON r.pedido_id = p.id
WHERE pr.productor = 'queseria-montblanc'
ORDER BY p.fecha DESC;Con pedidos en Cassandra (04-04), catalogo en PostgreSQL y reparto en su propio almacenamiento, esa consulta ya no existe. Hay tres formas de reconstruirla, y elegir bien entre ellas es una decisión de arquitectura tan importante como el corte de los servicios.
| Técnica | Cómo funciona | Cuándo encaja | Coste |
|---|---|---|---|
| Composición en la API | Un componente (el BFF, o el propio servicio que atiende la petición) llama a varios servicios y une las respuestas en memoria | Pocas llamadas, datos pequeños, necesidad de frescura inmediata | Latencia = suma de llamadas (o el máximo si son paralelas); la disponibilidad es el producto de las disponibilidades; sin paginación ni filtro cruzado eficientes |
| Vista materializada por eventos | El servicio que necesita la consulta se suscribe a los eventos de los demás y mantiene una copia desnormalizada en su propia base, con la forma exacta de la consulta | Consultas frecuentes, filtros y ordenaciones cruzadas, tolerancia a segundos de retraso | Consistencia eventual; almacenamiento duplicado; código de proyección que mantener |
| CQRS | Separar formalmente el modelo de escritura (comandos, validaciones, agregados) del modelo de lectura (vistas construidas a partir de los eventos), incluso en almacenes distintos | Cargas de lectura y escritura muy diferentes, muchas vistas distintas de los mismos hechos | Dos modelos que mantener; la lectura no ve la escritura al instante |
Las tres técnicas se apoyan en piezas que el curso ya tiene: la composición usa los clientes gRPC con timeouts y circuit breaker (02-03, 07-04); las vistas materializadas usan el consumidor idempotente y la outbox de 02-05; y CQRS generaliza las vistas materializadas.
Consistencia eventual como norma. En 03-05 la saga sustituyó la transacción global por pasos locales con compensaciones; la consecuencia para las consultas es que, durante unos milisegundos o segundos, el pedido P-2026-000125 existe en pedidos pero aún no en la vista del productor. En una arquitectura de microservicios eso no es un defecto que corregir sino el estado normal, y el diseño lo asume: la interfaz muestra "actualizado hace 3 s", los tests aceptan la ventana (07-06), y la única pregunta que se hace por cada dato es "¿qué retraso tolera quien lo lee?". La tabla de 03-01 de modelos de consistencia por dato es la que se revisa aquí: fuerte solo para el stock disponible y el estado del cobro; eventual para todo lo demás.
- CQRS: el panel de pedidos de un productor y la vista
productos_vista
productos_vistaEl panel del productor como problema de CQRS
Marta Puig, de la Quesería Montblanc, abre su panel y espera ver sus pedidos de los últimos 30 días con producto, unidades, estado y hora prevista de entrega, filtrables por mercado y ordenables por fecha. Los hechos viven en tres servicios: el pedido y sus líneas en pedidos, el nombre del producto en catalogo, la asignación y la hora prevista en reparto. Por composición en la API haría falta leer todos los pedidos (Cassandra no filtra por productor: su clave es el cliente, 04-04), consultar catalogo por cada producto y reparto por cada pedido, y ordenar en memoria: inviable a partir de unos cientos de pedidos.
CQRS lo resuelve separando los dos lados:
- El lado de comandos sigue como está:
pedidosrecibe "crear pedido", ejecuta la saga, escribe en Cassandra y publica enpedidos.eventosa través de la outbox. Es el modelo optimizado para escribir correctamente: validaciones, idempotencia, estados. - El lado de consultas es un consumidor nuevo dentro de
pedidos(o un servicio de consulta propio, si crece) que se suscribe apedido.creado,pago.confirmado,pago.rechazadoy a los eventos de asignación dereparto, y mantiene una tablapedidos_por_productoren PostgreSQL con exactamente las columnas y el índice que el panel necesita. Es el modelo optimizado para leer rápido.
flowchart LR
PAN[Panel de Marta] -- "1. POST /pedidos (comando)" --> W[pedidos: lado de escritura<br/>saga, outbox]
W --> C[(Cassandra<br/>km0_pedidos)]
W -. "pedido.creado, pago.confirmado" .-> K[(Kafka<br/>pedidos.eventos)]
REP[reparto] -. "reparto.asignado" .-> K
K --> P[Proyección<br/>consumidor idempotente]
P --> V[(PostgreSQL<br/>pedidos_por_productor)]
PAN -- "2. GET /productores/queseria-montblanc/pedidos (consulta)" --> R[pedidos: lado de lectura]
R --> V
Los dos lados comparten el nombre pedidos y el equipo, pero no el esquema ni la base de datos: la escritura no sabe que existe la vista, y la vista se puede reconstruir desde cero releyendo el tópico desde el principio (la retención de Kafka de 02-04 es lo que lo permite), lo que también es la forma de añadir una columna nueva al panel sin migración.
Código: la vista productos_vista en catalogo
El mismo patrón, en su forma más simple, es el que catalogo usa para saber si un producto está disponible sin consultar a inventario. En 04-05 el evento stock.actualizado invalidaba la caché de Redis; aquí, además, alimenta una tabla desnormalizada en la base de datos de catalogo, de modo que la ficha del producto y los listados filtrables por disponibilidad se sirven con una sola consulta local.
-- km0/sql/catalogo/productos_vista.sql
-- Vista materializada "a mano": la mantiene el consumidor, no PostgreSQL.
CREATE TABLE IF NOT EXISTS productos_vista (
slug TEXT NOT NULL,
mercado TEXT NOT NULL,
nombre TEXT NOT NULL, -- copiado del modelo de catalogo
productor TEXT NOT NULL,
precio_cents INTEGER NOT NULL,
disponible BOOLEAN NOT NULL DEFAULT FALSE,
unidades_aprox INTEGER NOT NULL DEFAULT 0, -- "aprox": es eventual por diseño
version_stock BIGINT NOT NULL DEFAULT 0, -- último fecha_ms aplicado de inventario
actualizado_en TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (slug, mercado)
);
CREATE INDEX IF NOT EXISTS idx_productos_vista_disp ON productos_vista (mercado, disponible, productor);# km0/servicios/catalogo/proyeccion_stock.py
"""Proyección CQRS: mantiene productos_vista a partir de stock.actualizado.
Reutiliza el consumidor idempotente de 02-05 (tabla mensajes_procesados) y la
invalidación de Redis de 04-05. Solo añade la escritura en la vista.
"""
import json
import psycopg
from confluent_kafka import Consumer
from servicios.comun import logs, metricas
from servicios.catalogo.cache import invalidar_producto # 04-05
log = logs.obtener("catalogo.proyeccion")
UMBRAL_DISPONIBLE = 1 # una unidad libre ya cuenta como "disponible"
def aplicar(conn: psycopg.Connection, evento: dict) -> None:
"""Aplica un stock.actualizado a la vista. Idempotente y tolerante al desorden."""
d = evento["datos"]
with conn.transaction():
# 1. Deduplicar por id_evento (02-05): si ya se aplicó, salir sin tocar nada.
ya = conn.execute(
"INSERT INTO mensajes_procesados (id_mensaje, consumidor) VALUES (%s, 'catalogo.proyeccion') ON CONFLICT DO NOTHING RETURNING 1",
(evento["id_evento"],)).fetchone()
if ya is None:
metricas.contador("km0_proyeccion_duplicados_total").inc()
return
# 2. Escribir solo si el evento es más reciente que lo ya aplicado (01-05: ordenación).
# Si llega un evento antiguo por un reintento tardío, la condición lo descarta.
n = conn.execute(
"""
UPDATE productos_vista
SET disponible = %(disp)s, unidades_aprox = %(uds)s,
version_stock = %(v)s, actualizado_en = now()
WHERE slug = %(slug)s AND mercado = %(mercado)s AND version_stock < %(v)s
""",
{"slug": d["producto"], "mercado": d["mercado"],
"uds": d["disponible"], "disp": d["disponible"] >= UMBRAL_DISPONIBLE,
"v": evento["fecha_ms"]}).rowcount
if n:
invalidar_producto(d["producto"], d["mercado"]) # la caché de 04-05 sigue siendo válida
log.info("vista actualizada", producto=d["producto"], mercado=d["mercado"], aplicado=bool(n))
def bucle(conn: psycopg.Connection, consumidor: Consumer) -> None:
consumidor.subscribe(["pedidos.eventos"])
while True:
msg = consumidor.poll(1.0)
if msg is None or msg.error():
continue
evento = json.loads(msg.value())
if evento["tipo"] != "stock.actualizado":
continue # esta proyección ignora los demás tipos
aplicar(conn, evento)
consumidor.commit(msg) # ack después de escribir: at-least-once + idempotencia (02-05)Explicación de las decisiones que el código encierra:
- La fila de
productos_vistala creacatalogocuando el productor publica el producto (condisponible = FALSE); la proyección solo la actualiza. Si llega un evento de stock de un producto aún no publicado, elUPDATEno afecta a ninguna fila y se ignora sin error: no es un problema porque, cuando se publique,catalogopedirá el stock inicial porConsultarStock(02-03). version_stock < fecha_mses la defensa contra el desorden: con 6 particiones enpedidos.eventosy clave = id de pedido, dos eventos del mismo producto pueden ir a particiones distintas y llegar invertidos. Sin esta condición, un evento viejo pisaría a uno nuevo. Es el mismo razonamiento delSeguimientoRepartidorde 01-05.- El
commitdel offset va después de la transacción: si el proceso muere en medio, el evento se reprocesa ymensajes_procesadoslo descarta. unidades_aproxlleva "aprox" en el nombre a propósito: es una decisión de lenguaje ubicuo. Nadie encatalogodebe usar ese número para decidir si se puede vender; eso esReservarStockeninventariocon consistencia fuerte.
Código: el bounded context como paquete con API pública
La otra mitad de "dueño de sus datos" es que el resto del código no pueda saltarse la frontera. Dentro de un servicio Python, la convención de Kilómetro Cero es que cada bounded context es un paquete con un único módulo público, api.py, y todo lo demás privado. Quien importe servicios.inventario.repositorio desde pedidos está cometiendo el mismo error que un JOIN cruzado.
# km0/servicios/inventario/api.py
"""API pública del bounded context `inventario`.
Es el ÚNICO módulo de este paquete que otros contextos pueden importar (en el
mismo proceso, durante la migración) o exponer por gRPC (contratos/inventario.proto).
Todo lo que no está aquí es detalle de implementación y puede cambiar sin aviso.
"""
from dataclasses import dataclass
from servicios.inventario import _dominio, _repositorio, _eventos # privados: prefijo _
__all__ = ["Reserva", "StockInsuficiente", "reservar_stock", "liberar_reserva", "consultar_stock"]
@dataclass(frozen=True)
class Reserva:
id_reserva: str
pedido_id: str
caduca_en_s: int
class StockInsuficiente(Exception):
"""Error de negocio, no técnico: la saga de 03-05 lo trata como fallo permanente."""
def reservar_stock(id_reserva: str, pedido_id: str, mercado: str, lineas: list[dict]) -> Reserva:
"""Reserva unidades de varias referencias en un mercado, atómicamente.
Idempotente por id_reserva: repetir la llamada devuelve la misma Reserva.
Publica stock.reservado y stock.actualizado vía outbox en la misma transacción.
"""
with _repositorio.transaccion() as tx:
if (existente := _repositorio.buscar_reserva(tx, id_reserva)):
return existente
for linea in lineas:
ref = _repositorio.bloquear_referencia(tx, linea["producto"], mercado) # SELECT ... FOR UPDATE
_dominio.reservar(ref, linea["unidades"]) # lanza StockInsuficiente
_repositorio.guardar_referencia(tx, ref)
_eventos.publicar(tx, "stock.actualizado", {"producto": ref.slug, "mercado": mercado,
"disponible": ref.disponibles})
reserva = _repositorio.crear_reserva(tx, id_reserva, pedido_id, lineas, caduca_en_s=900)
_eventos.publicar(tx, "stock.reservado", {"pedido_id": pedido_id, "id_reserva": id_reserva})
return reserva
def liberar_reserva(id_reserva: str) -> None:
"""Compensación de la saga (C2 en 03-05). Idempotente: liberar dos veces no duplica stock."""
with _repositorio.transaccion() as tx:
reserva = _repositorio.buscar_reserva(tx, id_reserva)
if reserva is None or reserva.liberada:
return
for linea in reserva.lineas:
ref = _repositorio.bloquear_referencia(tx, linea["producto"], reserva.mercado)
_dominio.liberar(ref, linea["unidades"])
_repositorio.guardar_referencia(tx, ref)
_eventos.publicar(tx, "stock.actualizado", {"producto": ref.slug, "mercado": reserva.mercado,
"disponible": ref.disponibles})
_repositorio.marcar_liberada(tx, id_reserva)
_eventos.publicar(tx, "stock.liberado", {"pedido_id": reserva.pedido_id, "id_reserva": id_reserva})
def consultar_stock(producto: str, mercado: str) -> int:
"""Lectura con consistencia fuerte (va al primario de Patroni). Para la vista de catalogo
NO se usa esto: se usa la proyección por eventos."""
return _repositorio.leer_disponibles(producto, mercado)El servidor gRPC de 02-03 (ReservarStock, ConsultarStock) no hace más que traducir mensajes protobuf a estas tres funciones, y el ServicioInterceptor de 06-04 decide quién puede llamar a cada una. La ventaja de tener el contexto como paquete con API es que durante la migración (apartado 8) el monolito pudo importar servicios.inventario.api en proceso antes de que existiera el servicio remoto, y el cambio a gRPC no tocó a los llamantes.
- Comunicación: síncrona, asíncrona, APIs, gateway, BFF y mesh
Síncrona o asíncrona, caso por caso
El Módulo 2 dio las herramientas (gRPC en 02-03, Kafka y RabbitMQ en 02-04, patrones asíncronos en 02-05). Lo que corresponde a esta lección es el criterio para elegir, que se resume en una pregunta: ¿el llamante necesita la respuesta para continuar, y la necesita ahora?
| Situación | Elección | Razón | Ejemplo en Kilómetro Cero |
|---|---|---|---|
| El resultado decide el siguiente paso y debe ser fuerte | Síncrona (gRPC) | La saga no puede avanzar sin saber si hay stock | pedidos → inventario.ReservarStock |
| Una persona espera la respuesta en pantalla | Síncrona, con timeout corto y degradación | Ana no espera más de 500 ms (SLO de 07-01) | catalogo sirviendo la ficha; pedidos aceptando el pedido |
| Varios interesados en un mismo hecho | Asíncrona (evento) | El productor no debe conocer a los consumidores | pago.confirmado → reparto, analitica, catalogo, Lambda de facturas (08-04) |
| El receptor puede estar caído sin que importe | Asíncrona | Desacoplamiento temporal | analitica consumiendo todo |
| Trabajo largo o por lotes | Asíncrona (cola o evento) | No bloquear al llamante | Generar la factura; recalcular previsiones |
| Alto volumen con orden por clave | Asíncrona (Kafka con clave) | Orden por partición y replay | reparto.posiciones |
| Notificación al exterior con confirmación del tercero | Síncrona hacia fuera, asíncrona hacia dentro | La pasarela responde síncrona; el resultado se propaga como evento | pagos → pasarela; luego pago.confirmado |
La regla práctica de Kilómetro Cero, fijada en el ejercicio 2 de 01-06 y confirmada aquí: síncrono solo cuando la respuesta gobierna la siguiente decisión; todo lo demás, eventos. Cada llamada síncrona añade una dependencia de disponibilidad (la disponibilidad de la cadena es el producto de la de sus eslabones) y de latencia (la suma), y por eso el mapa de contextos del apartado 3 solo tiene dos flechas continuas entre servicios.
API pública frente a API interna
No todas las interfaces son iguales. Una API pública (la que consume el navegador de Ana o la app de repartidores, y en el futuro terceros) es un compromiso a largo plazo: se versiona, se documenta (OpenAPI), se protege en el gateway, se limita por tasa y se mantiene compatible durante meses. Una API interna (gRPC entre pedidos e inventario) tiene pocos consumidores conocidos, cambia con más libertad y su compatibilidad se verifica con las pruebas de contrato de 07-06. Confundirlas lleva o a exponer detalles internos (el id_reserva no le interesa a Ana) o a burocratizar cambios internos.
Versionado de APIs
Toda API pública cambia. La disciplina tiene dos niveles. Los cambios compatibles (añadir un campo opcional en la respuesta, añadir un endpoint, aceptar un parámetro nuevo con valor por defecto) no necesitan versión nueva: el cliente antiguo ignora lo que no conoce. Los cambios incompatibles (renombrar o quitar un campo, cambiar su tipo o su semántica, cambiar códigos de error) exigen una versión nueva coexistiendo con la antigua durante un periodo anunciado. Kilómetro Cero versiona en la ruta (/api/v1, /api/v2), que es la opción más visible y la que Kong enruta sin ambigüedad (06-05).
# km0/servicios/pedidos/api_http.py (fragmento): v1 y v2 servidas por el mismo código
from fastapi import FastAPI, Header
from pydantic import BaseModel
app = FastAPI()
class PedidoV1(BaseModel):
id: str
estado: str
total: float # en euros, con decimales: la decisión original
class PedidoV2(BaseModel):
id: str
estado: str
total_cents: int # cambio INCOMPATIBLE: tipo y semántica distintos
moneda: str = "EUR"
entrega_prevista: str | None = None # campo NUEVO opcional: habría sido compatible en v1
def _cargar(pedido_id: str) -> dict:
... # repositorio de Cassandra (04-04); el modelo interno guarda total_cents
@app.get("/api/v1/pedidos/{pedido_id}", response_model=PedidoV1, deprecated=True)
def pedido_v1(pedido_id: str):
p = _cargar(pedido_id)
# Adaptador: el modelo interno ya es v2; v1 se DERIVA de él, nunca al revés.
return PedidoV1(id=p["id"], estado=p["estado"], total=p["total_cents"] / 100)
@app.get("/api/v2/pedidos/{pedido_id}", response_model=PedidoV2)
def pedido_v2(pedido_id: str):
p = _cargar(pedido_id)
return PedidoV2(id=p["id"], estado=p["estado"], total_cents=p["total_cents"],
entrega_prevista=p.get("entrega_prevista"))Tres detalles importan más que el código: la versión antigua se marca deprecated y se anuncia una fecha de retirada (cabecera Sunset en la respuesta y aviso a los consumidores registrados en Kong); se mide quién sigue usando v1 (la métrica km0_http_requests_total{ruta="/api/v1/..."} de 07-01 dice cuándo se puede apagar); y el modelo interno nunca se mantiene en dos formas: v1 es una traducción del modelo actual. Para los eventos, el versionado ya se trató en 02-05 (version en la envoltura, compatibilidad hacia atrás en el esquema).
API gateway y BFF
El gateway de 06-05 es el borde de la API pública: enrutado, JWT, rate limiting, X-Request-Id. Desde el punto de vista arquitectónico, su función es que los clientes conozcan un dominio y no seis servicios, y que las preocupaciones transversales vivan en un solo sitio. Lo que no debe hacer es componer respuestas: cuando un cliente necesita datos de varios servicios en una pantalla, el BFF (Backend for Frontend) es el lugar, uno por tipo de cliente, propiedad del equipo de ese cliente. La pantalla de inicio de la app de repartidores (ruta del día, próximos bultos, incidencias) es el caso previsto en 06-05 y que el proyecto final (08-05) sitúa en la arquitectura.
Service mesh: cuándo merece la pena
En 06-04 se vio el mesh como forma de tener mTLS sin código, y en 07-05 como sidecar que da timeouts, reintentos, outlier detection y telemetría RED por configuración; ambas remitieron aquí la decisión. El criterio es de número y heterogeneidad: con seis servicios, todos en Python y con servicios/comun/{resiliencia,metricas,trazas}.py compartidos, el mesh añade 1-2 ms por salto, memoria por sidecar y un plano de control que operar, a cambio de poco que la librería común no dé ya. Compensa cuando aparecen servicios en otros lenguajes (un recomendaciones en Go o Java que no puede usar la librería Python), cuando el número de servicios supera la decena y la uniformidad de políticas se vuelve un problema de gobierno, o cuando la seguridad exige mTLS obligatorio auditable (PeerAuthentication: STRICT) sin fiarse de que cada equipo lo configure bien. Kilómetro Cero lo adopta en la arquitectura final (08-05) por esta última razón y porque el coste de operarlo lo asume la plataforma interna (apartado 9), no los equipos de producto.
- Patrones de migración: evaluación del plan de estrangulamiento
En 01-06 se fijó un plan de estrangulamiento (strangler fig) en cinco fases. Ahora, con todas las piezas construidas, es el momento de evaluarlo fase a fase: qué se extrajo, con qué patrón, y qué se aprendió. Antes, los cuatro patrones que se usaron.
- Strangler fig: se coloca una fachada delante del monolito (en Kilómetro Cero, Kong), se implementa una capacidad en un servicio nuevo y se redirige a él solo esa ruta; el monolito pierde responsabilidades hasta quedar vacío. En ningún momento hay dos sistemas que mantener enteros.
- Anti-corruption layer (ACL): una capa de traducción entre el modelo nuevo y el antiguo (o uno externo), para que el modelo del servicio nuevo no herede los defectos del monolito. Es la relación "capa anticorrupción" del mapa de contextos.
- Branch by abstraction: dentro del monolito, se introduce una interfaz (
InventarioPuerto) con dos implementaciones, la local y la remota, seleccionable por configuración; se prueba la remota con un porcentaje del tráfico y se retira la local cuando está validada. Permite migrar sin ramas de código de larga duración. - Migración de datos con doble escritura y CDC: para mover una tabla del monolito a la base del servicio nuevo sin parada: primero se copia el histórico; después el monolito escribe en ambas (doble escritura, con la nueva como secundaria) o, mejor, un proceso de Change Data Capture (Debezium leyendo el WAL de PostgreSQL, el mismo WAL de 03-04) replica cada cambio al nuevo almacén; se verifica la igualdad; se invierte la dirección (el servicio nuevo es primario y el monolito lee de él); y se apaga la copia antigua.
| Fase (01-06) | Qué se extrajo | Patrones aplicados | Lecciones donde se construyó | Qué se aprendió |
|---|---|---|---|---|
| 1 | catalogo con Redis y km0-fotos |
Strangler fig por la ruta /api/v1/catalogo; datos copiados en frío (solo lecturas, sin doble escritura) |
04-03, 04-05, 06-05 | Empezar por lecturas quita riesgo: el retorno al monolito era cambiar una ruta en Kong. El síntoma 1 desapareció antes de tocar nada transaccional |
| 2 | pagos aislado |
ACL frente a la pasarela; branch by abstraction (PagosPuerto local/remoto); Idempotency-Key |
02-05, 07-04 | La ACL evitó arrastrar al servicio nuevo el modelo de "cobro dentro de la transacción del pedido". El circuit breaker resolvió el síntoma 2 incluso antes de extraer pedidos |
| 3 | reparto con Kafka y tiempo real |
Nuevo tópico reparto.posiciones; la tabla posiciones dejó de escribirse en PostgreSQL (sin migrar histórico: datos que caducan) |
02-04, 04-04, 05-04, 08-02 | Los datos de serie temporal no se migran, se dejan de escribir en el sitio equivocado. Fue la fase que introdujo el bus, del que luego se colgó todo |
| 4 | pedidos e inventario |
Doble escritura + CDC para pedidos (a Cassandra) y para inventario (a km0_inventario); saga sustituyendo a la transacción; outbox |
03-04, 03-05, 04-04, 02-05 | La fase más larga y arriesgada: dos migraciones de datos con verificación de igualdad durante tres semanas. Se descubrieron 0,4 % de pedidos con líneas huérfanas en el monolito, que la migración limpió |
| 5 | analitica sobre Kafka y HDFS |
Consumidor conformista de todos los tópicos; lago /km0/eventos; DAG km0_ventas_diarias |
04-02, 05-02, 05-03, 05-05 | La última porque dependía de que todo estuviera ya en eventos. Las consultas nocturnas contra producción desaparecieron y el síntoma 5 con ellas |
| Transversal | Seguridad, observabilidad, Kubernetes | Introducidos desde la fase 1 (Kong, Prometheus, trazas) y ampliados en cada fase | Módulos 6 y 7 | Lo que no se puso desde el principio (las trazas llegaron en la fase 2) costó el doble de poner después |
Una observación sobre el orden: el plan de 01-06 puso reparto (fase 3) antes que pedidos (fase 4) aunque el síntoma 3 (despliegues) fuera el que los equipos sentían cada semana. Fue una decisión correcta por una razón que entonces no se hizo explícita: reparto obligó a montar Kafka, y sin Kafka la fase 4 no habría podido usar la outbox ni las vistas materializadas. El orden de una migración lo dicta la infraestructura que cada fase deja a la siguiente, no solo el dolor que alivia.
- Organización: equipos, Conway y plataforma interna
La ley de Conway (1967) dice que un sistema refleja la estructura de comunicación de la organización que lo construye. Es una ley empírica, pero contrastada: si dos equipos deben ponerse de acuerdo para cada cambio, aparecerá una interfaz entre sus componentes; si un equipo posee dos servicios, tenderán a acoplarse porque nadie paga el coste de mantenerlos separados. La consecuencia práctica, a veces llamada maniobra inversa de Conway, es que la organización se diseña para obtener la arquitectura deseada: en 01-06 Kilómetro Cero tenía cuatro equipos (catálogo y productores, pedidos y pagos, reparto, datos), y las fronteras de servicio siguieron esas líneas. pedidos y pagos son dos servicios del mismo equipo, y de hecho son los dos que más se llaman síncronamente: Conway en acción.
Cuatro reglas de organización que la arquitectura necesita para sostenerse:
- Equipos alineados a flujo, dueños de una capacidad de negocio de extremo a extremo (código, datos, despliegue, guardia), en lugar de equipos por capa (frontend, backend, base de datos), que convierten cada funcionalidad en un proyecto de tres equipos.
- Un equipo de plataforma interna que ofrece Kubernetes, el mesh, Kafka, la observabilidad, el pipeline de despliegue y las librerías
servicios/comun/como un producto con usuarios (los equipos de producto), con documentación y SLO propios. Sin él, cada equipo reinventa la infraestructura, y la uniformidad que hace posible operar seis servicios se pierde. En Kilómetro Cero es el antiguo equipo de "datos" ampliado con las personas que montaron Kubernetes en 07-05. - Contratos como acuerdos entre equipos: cambiar
inventario.protoo el esquema destock.actualizadoes una negociación con los consumidores, verificada por las pruebas de contrato de 07-06, no una decisión unilateral. - Guardia por servicio, no una guardia central: el equipo que despliega es el que recibe la alerta de su SLO (07-01, 07-03). Es lo que alinea los incentivos para hacer servicios operables.
- Cuándo no usar microservicios y el retorno al monolito modular
Kilómetro Cero necesitaba microservicios porque tenía cinco síntomas medibles y cuatro equipos. La mayoría de las aplicaciones no los tienen, y en ellas una arquitectura de microservicios es un impuesto sin contrapartida: cada frontera de red cuesta latencia, fallos parciales, consistencia eventual, contratos, despliegues y guardias.
| Señal | Qué indica | Qué hacer en su lugar |
|---|---|---|
| Un solo equipo (menos de 8-10 personas) | No hay conflicto de despliegue que resolver | Monolito modular con paquetes por dominio y API interna (api.py) por paquete |
| El dominio aún no se entiende bien (producto nuevo, pivotes frecuentes) | Las fronteras que se corten hoy serán las equivocadas mañana, y mover código entre servicios es caro | Monolito modular; cortar cuando el lenguaje se estabilice |
| Todo el tráfico cabe en dos o tres réplicas del proceso | No hay necesidad de escalado diferenciado | Escalar el monolito horizontalmente; caché y réplicas de lectura |
| La mayoría de operaciones necesitan transacciones sobre varios "dominios" | Cada frontera obligaría a una saga | Mantener juntos los datos que cambian juntos |
| No hay capacidad de operar Kubernetes, Kafka, observabilidad distribuida | La plataforma consumirá al equipo | Un proceso, una base de datos, un despliegue sencillo; PaaS |
| Latencia extremadamente sensible (trading, juegos) | Cada salto de red cuesta | Módulos en proceso |
| El objetivo es "modernizar" sin síntoma concreto | Complejidad sin justificación (error de 01-06: "elegir tecnologías antes que problemas") | Escribir los síntomas; si no hay, no migrar |
El retorno al monolito modular no es un fracaso, y hay casos públicos de empresas que han reagrupado servicios que se llamaban en cadena para todo. Las señales para reagrupar dos servicios son las inversas de las de cortar: cambian siempre juntos, todos sus cambios exigen desplegarlos a la vez, la traza de cualquier operación los atraviesa siempre a ambos, o los posee el mismo equipo y no tienen necesidades de escalado distintas. En Kilómetro Cero, si en dos años pagos no tuviera más lógica que el adaptador a la pasarela, sería candidato a volver dentro de pedidos como paquete con su api.py: la ACL y la idempotencia se conservarían; la frontera de red, no.
- Registrar las decisiones: el ADR-001
En 01-06 se dio el consejo de mantener un registro de decisiones de arquitectura. Un ADR (Architecture Decision Record) es un documento breve, numerado, inmutable una vez aceptado (si la decisión cambia, se escribe otro ADR que lo sustituye), con cuatro partes: contexto, decisión, alternativas consideradas y consecuencias. Su valor no está en la decisión, que suele conocerse, sino en el contexto y las consecuencias, que son lo que se olvida y lo que dentro de un año permitirá saber si la decisión sigue siendo válida. Este es el primero de Kilómetro Cero, que el proyecto final (08-05) completará hasta el ADR-006.
# ADR-001: Extraer `inventario` del monolito como servicio con base de datos propia
- **Estado**: Aceptado (fase 4 del plan de estrangulamiento)
- **Fecha**: 2026-03-10
- **Decisores**: equipo de pedidos y pagos, equipo de plataforma
- **Sustituye a**: —
## Contexto
El stock vive en la tabla `productos.unidades` del monolito y se descuenta dentro de la
transacción de confirmación de pedido, que abarca también el cobro en la pasarela externa.
Síntomas (01-06): (2) la lentitud de la pasarela mantiene bloqueos de stock durante 30 s y
agota las conexiones de PostgreSQL; (3) el equipo de pedidos no puede desplegar cambios en
las reglas de reserva sin arrastrar al catálogo y al reparto. El stock exige consistencia
fuerte por mercado (no vender la misma pieza dos veces), y solo `pedidos` lo modifica.
`catalogo` únicamente necesita saber si un producto está disponible, con retraso tolerable
de segundos.
## Decisión
Extraer `inventario` como bounded context propio, con base de datos `km0_inventario`
(PostgreSQL con Patroni, 03-04/07-03), API gRPC `contratos/inventario.proto`
(`ReservarStock`, `ConsultarStock`, `ObservarCambios`) y eventos `stock.actualizado`,
`stock.reservado`, `stock.liberado` publicados vía outbox. La reserva de stock pasa a ser
un paso de la saga de pedido (03-05), con caducidad de 15 minutos y compensación
`liberar_reserva`. Migración de datos con CDC (Debezium sobre el WAL del monolito) y
verificación de igualdad durante tres semanas antes de invertir la dirección.
## Alternativas consideradas
1. **Dejar el stock en `pedidos`** (misma base, sin frontera). Rechazada: acopla las
reglas de reserva al ciclo de despliegue de pedidos y no permite a `catalogo` conocer
la disponibilidad sin consultar la base de pedidos.
2. **Stock en Cassandra junto a los pedidos**. Rechazada: el descuento de stock necesita
comparar-y-actualizar con consistencia fuerte por fila; Cassandra lo ofrece con
transacciones ligeras (Paxos) a un coste alto y `inventario` no necesita su escala de
escritura (03-02, 04-04).
3. **Transacción distribuida 2PC entre `pedidos` e `inventario`**. Rechazada: bloqueos
mientras el coordinador decide, y la pasarela externa no participa en 2PC (03-05).
## Consecuencias
- (+) `pagos` lento ya no bloquea stock: la reserva tiene caducidad y la saga compensa.
- (+) Las reglas de reserva se despliegan de forma independiente.
- (+) `catalogo` obtiene la disponibilidad por eventos (vista `productos_vista`).
- (−) Consistencia eventual entre stock real y catálogo: ventana de segundos en la que
un producto agotado aparece disponible; se acepta y se maneja con "sin stock" en la
confirmación.
- (−) Una llamada síncrona más en el camino crítico de confirmar pedido (p99 +12 ms);
el SLO de 500 ms sigue cumpliéndose.
- (−) Nuevo componente con guardia: Patroni, backups y PITR de `km0_inventario` (07-03).
- (−) Los informes que hacían `JOIN` de stock con pedidos se reescriben sobre el lago
de eventos (fase 5).Los ADR viven en el repositorio (km0/docs/adr/), se revisan en pull request como el código y se enlazan desde el ticket que originó el cambio. Un ADR que solo dice "usamos Kafka" sin contexto ni consecuencias negativas no sirve: las consecuencias negativas son la parte que más valor tiene.
Errores Comunes y Consejos
- Cortar por tabla o por capa técnica. Produce servicios-entidad y cadenas síncronas. Corta por capacidad de negocio; si el servicio no tiene un verbo de negocio en su API, replantéalo.
- Compartir la base de datos "solo para lecturas". Es la forma más silenciosa de crear un monolito distribuido: el primer
ALTER TABLErompe al vecino. Vistas materializadas por eventos o composición en la API; nunca unSELECTcruzado. - Convertir cada consulta cruzada en una llamada síncrona. La disponibilidad se multiplica y la latencia se suma. Si la consulta es frecuente y tolera segundos de retraso, es una proyección CQRS.
- Versionar la API rompiendo la anterior sin periodo de coexistencia. Toda versión nueva convive con la antigua, marcada
deprecated, con fecha de retirada y con una métrica que diga quién la sigue usando. - Migrar en "big bang". Estrangular por rutas, empezar por lecturas, migrar datos con CDC y verificación, y conservar siempre la vuelta atrás (un cambio de ruta en Kong).
- Ignorar a Conway. Fronteras de servicio que no coinciden con equipos se acoplan o se pelean. Diseña la organización junto con la arquitectura.
- Adoptar microservicios sin síntomas. Escribe los síntomas antes de cortar. Si no los hay, el monolito modular con
api.pypor paquete es la respuesta correcta, y es reversible. - Consejo: cada bounded context con un solo módulo público; una prueba de arquitectura (con
import-lintero similar) que falle si alguien importa un módulo privado de otro contexto. - Consejo: escribe el ADR antes de la decisión, no después: obliga a enumerar alternativas y consecuencias negativas cuando aún se puede cambiar de idea.
Ejercicios
Ejercicio 1: cortar un dominio nuevo
Kilómetro Cero quiere añadir valoraciones: Ana puntúa de 1 a 5 cada producto que ha recibido y escribe un comentario; el productor responde; la ficha del catálogo muestra la media y los tres últimos comentarios; el panel del productor muestra sus valoraciones pendientes de respuesta. Solo se puede valorar un producto de un pedido entregado. Decide: (a) ¿es un bounded context propio o parte de catalogo o de pedidos? Justifícalo con las heurísticas del apartado 3. (b) ¿Qué comunicación tiene con los demás contextos (síncrona/asíncrona, y en qué dirección)? (c) ¿Cómo obtiene la ficha del catálogo la media de valoraciones sin llamar síncronamente al nuevo contexto?
Ejercicio 2: detectar el monolito distribuido
Un equipo propone esta implementación de "confirmar pedido": el BFF llama a pedidos, que llama a clientes (nuevo servicio que expone la tabla de usuarios) para validar la dirección, luego a precios (nuevo, calcula el importe con IVA) y a inventario, pagos y reparto en secuencia, todo síncrono; analitica consulta cada noche las tablas de pedidos con un usuario de solo lectura; y precios comparte con catalogo la librería km0-modelo con las clases Producto y Precio. Enumera cada violación de las cinco características del apartado 2, nómbrala (servicio-entidad, nanoservicio, monolito distribuido, base compartida...) y propón la corrección con las técnicas de la lección.
Ejercicio 3: escribir un ADR
Escribe el ADR-002, "Cassandra para pedidos", con las cuatro partes (contexto, decisión, alternativas rechazadas, consecuencias positivas y negativas), apoyándote en lo que se decidió en 03-02 y 04-04. Incluye al menos tres consecuencias negativas.
Soluciones
Ejercicio 1.
(a) Contexto propio, valoraciones. Lenguaje: "valoración", "media", "respuesta del productor" no existen en ningún otro contexto, y "producto" aquí es solo un identificador. Cohesión: las reglas (una valoración por línea entregada, moderación, respuesta única) cambian juntas y no con el catálogo. Tasa de cambio: es una funcionalidad nueva que evolucionará rápido (moderación, fotos, filtros), distinta del ritmo de pedidos. Datos que cambian juntos: la valoración no se escribe nunca en la misma transacción que el pedido ni que la ficha. Equipo: encaja en el de catálogo y productores, que puede poseer dos contextos. Meterlo en catalogo acoplaría la moderación de comentarios a los despliegues del catálogo en campaña; en pedidos contaminaría el contexto más crítico con lógica que no tiene que ver con la compra.
(b) Entrada: valoraciones consume pedido.entregado (evento de reparto, asíncrono) para saber qué líneas pueden valorarse: guarda (cliente, pedido, producto, entregado_en) en su propia base; así, al recibir "Ana valora queso-curado del P-2026-000124", comprueba localmente sin llamar a pedidos. Salida: publica valoracion.creada y valoracion.respondida en un tópico valoraciones.eventos. No hay ninguna llamada síncrona entre servicios: la única síncrona es del BFF/gateway a valoraciones para crear y listar.
(c) catalogo mantiene una proyección: consume valoracion.creada y actualiza en productos_vista (o una tabla productos_valoracion) suma_puntos, num_valoraciones y una lista de los tres últimos comentarios (JSON). La media se calcula al leer (suma/num) y tolera segundos de retraso. Con el consumidor idempotente, una valoración reprocesada no cuenta dos veces. Es exactamente la vista productos_vista del apartado 6 con otra fuente.
Ejercicio 2.
| Elemento propuesto | Violación | Nombre | Corrección |
|---|---|---|---|
clientes expone la tabla de usuarios y pedidos lo llama para validar la dirección |
Servicio sin capacidad de negocio; llamada síncrona para un dato estable | Servicio-entidad | La dirección de entrega viaja en el comando de crear pedido (el cliente la eligió en pantalla); la identidad ya viene en el JWT (06-01). pedidos guarda una copia de la dirección en el pedido (desnormalización legítima: la dirección del pedido no cambia si Ana se muda) |
precios calcula el importe con IVA en un servicio aparte |
Coste de red por una función pura | Nanoservicio | Función dentro de pedidos (la decisión de 01-06). Si las reglas de precio crecieran (tarifas por mercado, promociones complejas), sería un contexto precios con su propia lógica, pero consultado por evento o con precios publicados en el catálogo |
Cadena síncrona inventario → pagos → reparto |
reparto no gobierna la confirmación; disponibilidad multiplicada |
Monolito distribuido (acoplamiento temporal) | inventario y pagos síncronos dentro de la saga (03-05); reparto consume pago.confirmado de forma asíncrona y publica reparto.asignado |
analitica con usuario de solo lectura sobre las tablas de pedidos |
Rompe "dueño de sus datos"; acopla esquemas; carga nocturna en producción (síntoma 5) | Base de datos compartida | Consumidor conformista de pedidos.eventos hacia el lago (05-05) |
Librería km0-modelo compartida con clases de dominio |
Un cambio en Producto obliga a redesplegar ambos; niega el lenguaje ubicuo (mismo Producto para dos contextos) |
Monolito distribuido (acoplamiento por librería) | Cada contexto con su propio modelo; lo compartido se limita a servicios/comun/ (métricas, logs, trazas, resiliencia: infraestructura, no dominio) y a los contratos (.proto, esquemas de eventos), que se versionan |
Ejercicio 3. Un ADR-002 aceptable:
- Contexto. Los pedidos crecen 1,2 M al mes en campaña; el patrón de acceso es "pedidos de un cliente por fecha" y "pedido por id", sin consultas ad hoc; se exige disponibilidad para aceptar pedidos incluso con un nodo caído o una partición (03-02:
pedidoselige disponibilidad); PostgreSQL con Patroni tiene un primario único que limita la escritura y cuya conmutación (30 s) supondría rechazar pedidos en campaña. - Decisión.
km0_pedidosen Cassandra, 3 nodos, RF=3, tablaspedidos_por_clienteypedidos_por_id(desnormalizadas por consulta), escrituras conQUORUMy lecturas conLOCAL_QUORUMpara el estado del pedido yONEpara listados; tablasagasen el mismo keyspace. - Alternativas rechazadas. PostgreSQL particionado por cliente con Citus (mantiene SQL pero introduce un coordinador y transacciones distribuidas que el patrón de acceso no necesita); MongoDB (documentos encajan, pero el modelo de réplica con primario único tiene el mismo problema de conmutación); mantener PostgreSQL y aceptar los 30 s de indisponibilidad (rechazada por el síntoma 1: los picos coinciden con cuando más duele).
- Consecuencias. (+) Escritura lineal con nodos; sin conmutación de primario; tolera un nodo caído sin rechazar pedidos. (−) Sin
JOINni consultas ad hoc: cada pregunta nueva exige una tabla nueva o una vista CQRS (el panel del productor del apartado 6). (−) Consistencia ajustable que el código debe elegir en cada operación: un error de nivel es un bug silencioso. (−) Nueva tecnología que operar: compactaciones, reparaciones, snapshots (07-03), formación del equipo. (−) Las transacciones ligeras (Paxos) son caras: por eso el stock no va aquí (ADR-001).
Conclusión
Una arquitectura de microservicios es un sistema, y no una colección de servicios, cuando cada servicio cumple cinco propiedades: representa una capacidad de negocio, es dueño de sus datos, se despliega solo, lo posee un equipo y sus fallos no se propagan. Las fronteras se trazan donde el lenguaje cambia de significado (bounded contexts), guiadas por la cohesión, la tasa de cambio, los datos que cambian juntos y los equipos, y el mapa de contextos de Kilómetro Cero muestra el resultado: solo dos flechas síncronas (pedidos → inventario, pedidos → pagos) y todo lo demás por eventos. Renunciar al JOIN obliga a componer en la API, a mantener vistas materializadas por eventos o a separar formalmente escritura y lectura con CQRS, como el panel de la Quesería Montblanc y la tabla productos_vista, con la consistencia eventual como estado normal y no como defecto. La comunicación se elige caso por caso (síncrona solo cuando la respuesta gobierna la siguiente decisión), las APIs públicas se versionan con coexistencia y métricas de uso, el gateway y el BFF separan lo transversal de lo específico de cada cliente, y el mesh compensa cuando la heterogeneidad o la exigencia de mTLS obligatorio superan lo que una librería común puede dar. El plan de estrangulamiento de 01-06 se ha revisado fase a fase (strangler fig, ACL, branch by abstraction, CDC), la ley de Conway explica por qué la organización y la arquitectura deben diseñarse juntas, la tabla de señales dice cuándo el monolito modular es la respuesta correcta, y el ADR-001 deja escrito por qué inventario salió del monolito y qué se paga por ello.
Queda una pieza de la plataforma que este mapa solo ha nombrado: la flecha discontinua reparto.posiciones termina en Kafka, pero Ana no lee Kafka. Entre el tópico y su pantalla hay un último tramo con reglas propias (navegadores, móviles, dispositivos en redes malas, miles de conexiones abiertas), y ese tramo, con MQTT y WebSockets que 02-01 dejó presentados, es la siguiente lección: Sistemas de Mensajería en Tiempo Real.
Curso de Arquitecturas Distribuidas
Módulo 1: Introducción a los Sistemas Distribuidos
- Conceptos Básicos de Sistemas Distribuidos
- Modelos de Sistemas Distribuidos
- Ventajas y Desafíos de los Sistemas Distribuidos
- Las Falacias de la Computación Distribuida
- Tiempo, Relojes y Ordenación de Eventos
- Del Monolito a la Plataforma Distribuida: el Caso Kilómetro Cero
Módulo 2: Comunicación en Sistemas Distribuidos
- Protocolos de Comunicación
- RPC y RMI
- gRPC y Serialización de Datos
- Mensajería y Colas de Mensajes
- Patrones de Comunicación Asíncrona
Módulo 3: Consistencia y Replicación
- Modelos de Consistencia
- El Teorema CAP y PACELC
- Algoritmos de Consenso
- Replicación de Datos
- Transacciones Distribuidas y Sagas
Módulo 4: Almacenamiento Distribuido
- Particionado de Datos y Hashing Consistente
- Sistemas de Archivos Distribuidos
- Almacenamiento de Objetos
- Bases de Datos Distribuidas
- Cachés Distribuidos
Módulo 5: Computación Distribuida
- Modelos de Computación Distribuida
- MapReduce y Hadoop
- Spark y Computación en Memoria
- Procesamiento de Flujos de Datos
- Planificación de Trabajos y Pipelines de Datos
Módulo 6: Seguridad en Sistemas Distribuidos
- Autenticación y Autorización
- Cifrado y Protección de Datos
- Gestión de Identidades
- Seguridad entre Servicios: mTLS y Gestión de Secretos
- Puertas de Enlace, Limitación de Tasa y Auditoría
Módulo 7: Monitoreo y Mantenimiento
- Monitoreo de Sistemas Distribuidos
- Logs Centralizados y Trazabilidad Distribuida
- Gestión de Fallos y Recuperación
- Patrones de Resiliencia: Timeouts, Reintentos y Circuit Breaker
- Automatización y Orquestación
- Pruebas en Sistemas Distribuidos e Ingeniería del Caos
