🌊 Arquitectura avanzada — patrones
Microservicios, arquitecturas event-driven, sagas, transactional outbox, CQRS, event sourcing, circuit breaker, idempotencia y resiliencia. Teoría, código y práctica.
Arquitectura avanzada — patrones
Cuando el sistema crece, la pregunta ya no es «¿qué framework?» sino «¿qué patrón de arquitectura?». Este nivel cubre los patrones que resuelven los problemas de datos y fallos en sistemas distribuidos: microservicios, event-driven, sagas, outbox, CQRS, event sourcing y los patrones de resiliencia.
Los microservicios no son un fin: son una decisión con coste. Se empieza con un módulo-monolito bien estructurado y se extraen servicios cuando el tamaño o los equipos lo justifican (patrón Strangler Fig). Los patrones de aquí existen para que los sistemas sigan funcionando cuando las cosas fallan.
Microservicios: cuándo sí, cuándo no
El problema que resuelven
Un monolito es una app única que crece sin parar. Cuando crece demasiado:
- Se vuelve difícil de entender (todo acoplado).
- Cada deploy toca todo (release lento, arriesgado).
- Un equipo no puede trabajar sin pisarse con los demás.
- El escalado es «todo o nada» (escalas la parte que se usa poco).
Los microservicios parten la app en servicios independientes, cada uno con su propia base de datos (database-per-service), que se comunican por red.
MONOLITO MICROSERVICIOS
┌──────────────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ UI + auth + │ │ Auth │ │Orders │ │Payment │
│ orders + payment│ │ (DB) │ │ (DB) │ │ (DB) │
└──────────────────┘ └────────┘ └────────┘ └────────┘
│ │ │
(se comunican por API/eventos)
Beneficios reales
| Beneficio | En qué se nota |
|---|---|
| Despliegue independiente | Cambias Orders sin tocar Payment |
| Escala selectiva | Solo escalas el servicio saturado |
| Equipos autónomos | Cada equipo dueño de su servicio (ownership) |
| Aislamiento de fallos | Payment cae, Orders sigue |
Costes reales (los que nadie cuenta)
| Coste | Descripción |
|---|---|
| Complejidad de red | Latencia, timeouts, retries, particiones |
| Consistencia distribuida | Sin transacciones ACID entre servicios |
| Observabilidad | Ver una petición que cruza 5 servicios |
| Operación | Orquestar, versionar, desplegar N servicios |
| Testing | Integración entre servicios es costoso |
⚠️ La regla: módulo-monolito primero. Estructura el código como módulos claros (auth, orders, payment) dentro de una app. Si más adelante necesitas extraer, el Strangler Fig te lo permite. Los microservicios premios empiezan con 30 servicios y 10 personas: el anti-patrón más común.
Patrones de la era microservicios
| Patrón | Problema que resuelve |
|---|---|
| Service discovery | ¿Dónde está cada servicio? (DNS, Consul, etcd) |
| API gateway | Una puerta de entrada única (routing, auth, rate limit) |
| Circuit breaker | No golpear un servicio que ya falla |
| Bulkhead | Aislar los fallos por grupos (no se contagian) |
| Strangler Fig | Migrar del monolito por partes |
# Patrón gateway: una entrada, routing interno
# (Nginx/API Gateway)
routes:
- path: /api/auth → servicio-auth
- path: /api/orders → servicio-orders
- path: /api/pay → servicio-payment
El problema de los datos distribuidos
Cuando cada servicio tiene su BBDD, una operación de negocio que tocaba una tabla ahora toca varias BBDD con red de por medio. Ejemplo clásico: crear un pedido toca Orders + Payment + Inventory.
El anti-patrón: transacción distribuida (2PC)
La solución «obvia» es usar una transacción distribuida (two-phase commit). No lo hagas: exige locks globales, es lenta, y ante fallos puede quedarse en estados inconsistentes. Los sistemas modernos usan sagas en su lugar.
Saga: la transacción distribuida compensable
Una saga es una secuencia de pasos, cada uno con su transacción local. Si un paso falla, se ejecutan pasos compensatorios (deshacer) en orden inverso.
Crear pedido (Orders) ✓
│
Reservar inventario (Inv) ✓
│
Cobrar (Payment) ✗ FALLA
│
└──▶ Compensar: liberar inventario, anular pedido
Dos formas de orquestar la saga:
Orquestada (orchestration): un coordinador central (el orquestador) dirige cada paso.
[Orquestador] ──▶ 1. Reserva inventario
│
├──◀─ ok ──▶ 2. Cobra pago
│
├──◀─ ok ──▶ 3. Confirma pedido
│
└──◀─ fail ─▶ dispara compensaciones
Coreografiada (choreography): no hay coordinador; cada servicio reacciona a eventos de los demás.
Orders publica "pedido_creado"
→ Inventory escucha, reserva, publica "inventario_reservado"
→ Payment escucha, cobra, publica "pago_realizado"
→ Orders confirma
💡 Orquestada = más control, más acoplamiento al coordinador. Coreografiada = más desacoplada, pero el flujo se «esparce» por muchos servicios y es más difícil de seguir. Empieza por orquestada.
Event-driven: el sistema movido por eventos
Una arquitectura event-driven cambia la comunicación: en vez de llamadas directas (request/response), los servicios publican eventos y otros reaccionan. Los eventos van a un broker (Kafka, RabbitMQ, SNS/SQS).
[Orders] ──publica──▶ [Broker (Kafka)] ──suscritos──▶ [Inventory]
[Analytics]
[Emails]
Evento vs comando vs request
| Tipo | Qué es | Dirección |
|---|---|---|
| Comando | «Haz esto» (espera resultado) | 1 a 1, síncrono |
| Evento | «Esto pasó» (sin esperar respuesta) | 1 a muchos, asíncrono |
| Query | «Dame esto» (lee) | 1 a 1, síncrono |
El log como backbone (el patrón Kreps)
La idea central de Kafka: un log distribuido = secuencia ordenada y replicada de eventos. Todo lo que «pasa» en el sistema va al log, y cada consumidor lee lo que le importa.
Eventos: [pedido_creado] [pago_realizado] [email_enviado] [pedido_entregado]
Consumidores: cada uno lee en su posición (offset)
| Ventaja | Descripción |
|---|---|
| Replay | Puedes reproducir eventos desde el inicio (reconstruir estado, corregir bugs) |
| Desacoplamiento total | Productor y consumidores no se conocen |
| Persistencia | Los eventos quedan guardados (historia auditable) |
| Múltiples consumidores | Analytics, notificaciones, search index: todos leen el mismo log |
⚠️ La regla de oro del event-driven: los eventos son hechos del pasado en tiempo pasado («pedido_creado», no «crea_pedido»). Nombrarlos bien es más que estética: define el contrato entre servicios.
Transactional Outbox: publicar eventos sin perderlos
El problema: tienes una transacción que debe (1) guardar datos en tu BBDD y (2) publicar un evento. Si haces la transacción y luego publicas, y el proceso muere entre medias → perdiste el evento (y el sistema downstream nunca se entera). Si publicas primero y luego la transacción falla → publicaste un evento falso.
La solución (outbox): los dos cambios van en la misma transacción local. Publicas en una tabla outbox dentro de tu BBDD, y un relay lee esa tabla y publica en el broker.
Transacción:
INSERT INTO pedidos (...)
INSERT INTO outbox (evento, payload) ← misma transacción
Relay (proceso aparte):
lee outbox → publica en Kafka → marca como enviado
┌─────────────────────────────┐
│ [Orders service] │
│ BBDD: pedidos + outbox │ ← atómicos
└──────────────┬──────────────┘
│ relay (publica)
▼
[Kafka]
💡 Si la transacción se confirma, el evento está en outbox; si no, no se publicó nada. El relay garantiza que todo evento confirmado eventualmente llegue al broker (idempotente: publicar dos veces no rompe nada si el consumidor es idempotente). Es el patrón que resuelve la publicación de eventos sin perder ni duplicar.
CQRS: separar lecturas y escrituras
CQRS (Command Query Responsibility Segregation): las escrituras (commands) y las lecturas (queries) usan modelos separados.
ESCRITURAS LECTURAS
[Command: crea pedido] [Query: muestra pedidos]
│ │
▼ ▼
[Modelo de escritura] [Modelo de lectura (read model)]
│ │
└──sincronización (eventos)───┘
¿Cuándo tiene sentido? Cuando el modelo de escritura y el de lectura son muy distintos:
- El sistema escribe poco pero lee muchísimo (y las lecturas son complejas: dashboards, feeds).
- Escritura y lectura tienen requisitos de consistencia diferentes.
⚠️ CQRS no es gratis: añade la sincronización entre el write model y el read model (normalmente vía eventos + projection), y añade latencia (el read model puede ir por detrás → eventual consistency). Úsalo cuando el problema lo pide, no por moda. La combinación clásica es CQRS + Event Sourcing.
Event Sourcing: el estado es la historia
Event Sourcing: en vez de guardar el estado actual de una entidad, guardas la secuencia de eventos que lo produjeron. El estado actual se reconstruye (se proyecta) aplicando los eventos.
Estado de la cuenta = eventos:
[abrir_cuenta: 0€]
[ingreso: 100€]
[compra: -30€]
[ingreso: 50€]
─────────────────────────
saldo actual = 120€ (reconstruido)
No guardas "120€": guardas los eventos y DERIVAS el estado.
| Ventaja | Descripción |
|---|---|
| Auditoría perfecta | Tienes la historia completa (¿por qué llegó a este estado?) |
| Replay | Reproduces el estado en cualquier punto del tiempo |
| Proyecciones | Diferentes read models desde la misma historia |
| Corrección | Un bug de lógica se arregla re-proyectando con la nueva versión |
| Coste | Descripción |
|---|---|
| Complejidad | El modelo mental cambia (no guardas el «ahora», guardas el «cómo llegó») |
| Versionado de eventos | Los eventos viejos nunca se cambian; necesitas migración |
| Snapshotting | Reconstruir 1M de eventos es caro → se guardan snapshots periódicos |
💡 Event Sourcing + CQRS van de la mano: la historia de eventos es el modelo de escritura, y las proyecciones (read models) son el modelo de lectura. Es la arquitectura de sistemas financieros, de auditoría y de cualquier dominio donde «qué pasó» importa más que «cuál es el estado».
Patrones de resiliencia
Diseñar para el fracaso: los patrones que mantienen tu sistema vivo cuando algo se cae.
Circuit Breaker (disyuntor)
Cuando un servicio falla repetidamente, dejas de llamarlo por un tiempo (como el disyuntor eléctrico) en vez de golpearlo con cada petición.
Estado: CERRADO ──(errores)──▶ ABIERTO ──(timeout)──▶ MEDIO ABIERTO
(normal) (no llamo, (pruebo 1
devuelvo fallo rápido) petición)
│
┌────────────────────┘
▼ éxito → CERRADO | fallo → ABIERTO
# Concepto (con una librería tipo pybreaker / resilience4j / gobreaker)
breaker = CircuitBreaker(fail_max=5, reset_timeout=30)
def llamar_servicio():
with breaker:
return http.get("http://payment-service/cobrar")
# si el breaker está ABIERTO → salta rápido, sin golpear el servicio
💡 Por qué importa: sin circuit breaker, un servicio degradado recibe miles de peticiones que fallan despacio (timeouts), y cada una mantiene un thread ocupado → el problema se propaga en cascada a todo el sistema (fallo en cascada / cascading failure).
Bulkhead (compartimentos)
Aísla los recursos por grupos para que un fallo no agote todo. Si el servicio A se satura, no debe robar los threads/connections de B.
MAL: un pool de 100 para todo
A lo consume entero → B muere de hambre
BIEN: bulkheads separados
Pool A (50) → servicio A
Pool B (50) → servicio B ← A se cae, B sigue
Retries y backoff exponencial
Reintentar con backoff exponencial + jitter (espera que crece: 1s, 2s, 4s… más un poco de aleatoriedad para que no todos reintenten a la vez).
def con_retry(func, max_retries=5):
for intento in range(max_retries):
try:
return func()
except TransientError:
time.sleep(2 ** intento + random.uniform(0, 1)) # backoff + jitter
raise
⚠️ Regla: retries solo para errores transitorios (timeout, 503), nunca para 4xx (no van a arreglarse). Y todo reintento debe ser idempotente.
Idempotencia (la clave de todo)
Una operación es idempotente si ejecutarla 1 vez o 100 veces da el mismo resultado. Es la propiedad que hace seguros los retries, los eventos y las sagas.
# Cliente manda un ID de idempotencia
POST /api/pagos
{"pedido_id": 123, "monto": 50, "idempotency_key": "abc-123"}
# Servidor: si ya procesó esa key, devuelve el resultado guardado
def procesar_pago(pedido_id, monto, idempotency_key):
if existe(idempotency_key):
return resultado_previo(idempotency_key) # no cobra dos veces
resultado = cobrar(pedido_id, monto)
guardar(idempotency_key, resultado)
return resultado
💡 Stripe hizo esto famoso: el
Idempotency-Keydel cliente garantiza que reintentar un cobro no cobra dos veces. Es el patrón que necesitas en cualquier API que haga efectos reales (pagos, pedidos, emails).
Terminología que debes dominar
| Término | En una frase |
|---|---|
| Microservicio | Servicio independiente con su propia BBDD |
| Database-per-service | Cada servicio, su propia base de datos |
| Strangler Fig | Migrar del monolito por partes |
| Saga | Transacción distribuida con compensaciones |
| Compensación | Paso que deshace un paso anterior |
| Evento | Hecho del pasado publicado para quien escuche |
| Broker | Transporte de eventos (Kafka, RabbitMQ, SQS) |
| Log distribuido | Secuencia ordenada y replicada de eventos |
| Transactional outbox | Publicar eventos en la misma transacción de la BBDD |
| Relay | Proceso que lee el outbox y publica en el broker |
| CQRS | Modelos separados de lectura y escritura |
| Event sourcing | El estado se reconstruye desde la historia de eventos |
| Proyección (read model) | Vista derivada de los eventos |
| Circuit breaker | Dejar de golpear lo que falla |
| Bulkhead | Aislar recursos para que un fallo no contagie |
| Backoff / jitter | Reintentar esperando más / con aleatoriedad |
| Idempotencia | Reintentar da el mismo resultado |
| Exactly-once / at-least-once | Garantías de entrega de eventos |
Práctica propuesta
- Extrae un servicio de un monolito tuyo (patrón Strangler Fig) y pon un API gateway delante.
- Implementa una saga orquestada: 3 servicios (orders, inventory, payment) con compensación. Rompe payment y verifica que se deshace.
- Monta Kafka (o Redis streams) y publica/consumes eventos entre dos servicios con un outbox en PostgreSQL.
- Implementa un circuit breaker y un bulkhead en una app tuya. Simula el fallo del servicio y observa cómo el resto sigue vivo.
- Añade idempotencia (Idempotency-Key) a un endpoint que haga un efecto real. Verifica con un script que reintentar no duplica.
- Modela un domain con Event Sourcing (una cuenta bancaria o un carrito) con CQRS: comandos + eventos + proyección.
Para profundizar
- microservices.io: el catálogo del pattern language de Chris Richardson.
- Microservices Patterns (Richardson): el libro de los patrones distribuidos de datos.
- Building Microservices (Newman): cuándo y cómo partir servicios.
- Designing Event-Driven Systems (Stopford): event-driven + Kafka, gratis.
- The Log (Jay Kreps): el log como backbone.
- Release It! (Nygard): patrones de resiliencia de producción.
- DDIA: la teoría de garantías distribuidas.
- Sigue con 🌐 Sistemas distribuidos de élite o vuelve a 🏗️ Conceptos y casos.