Al separar las bases de datos hemos perdido, a sabiendas, la pieza que hacía cómodo el monolito: la transacción única de crearPedido, ese BEGIN ... COMMIT que reservaba stock, creaba el pedido, registraba el pago y, si algo fallaba, lo deshacía todo con un ROLLBACK. Ahora la reserva vive en la base de datos de Inventario, el pago en la de Pagos y el pedido en la de Pedidos, y no existe ningún ROLLBACK que abarque las tres. El esquema de 02-04 dejó las pistas: estados STOCK_RESERVADO y PAGADO en pedidos, expira_en en reservas, un motivo_cancelacion. Esta lección explica qué sustituye a esa transacción.
Veremos por qué no hay transacciones ACID entre servicios (y por qué el two-phase commit se descarta), qué significa en la práctica el teorema CAP y la consistencia eventual, el patrón saga en sus dos variantes (coreografía y orquestación), el diseño completo de la saga "crear pedido" de TechCorp con los eventos del curso, sus transacciones compensatorias y la máquina de estados del pedido, la decisión razonada de empezar por coreografía, el patrón outbox transaccional que garantiza que un evento se publica si y solo si el cambio se guardó, la idempotencia de los consumidores, y las dos ideas que suelen acompañar a las sagas: CQRS (modelos separados de escritura y lectura) y event sourcing (guardar eventos en lugar de estado), con la decisión de TechCorp sobre cada una. Todo a nivel de diseño: cómo se configura RabbitMQ y cómo se publica un evento desde Node.js se ve en 03-02 y 04-04.
Contenido
- Por qué no hay transacciones ACID entre servicios
- El teorema CAP y la consistencia eventual, en términos prácticos
- El patrón saga: coreografía frente a orquestación
- La saga "crear pedido" de TechCorp
- La máquina de estados del pedido
- La decisión de TechCorp: coreografía para empezar
- El patrón outbox transaccional
- Idempotencia de los consumidores y claves de idempotencia
- CQRS: separar el modelo de escritura del de lectura
- Event sourcing: guardar los hechos en lugar del estado
- Por qué no hay transacciones ACID entre servicios
Una transacción ACID (atómica, consistente, aislada, duradera) es una promesa que hace un motor de base de datos sobre sus datos. En cuanto los datos están en dos motores (PostgreSQL de Pedidos y PostgreSQL de Inventario, o PostgreSQL y MongoDB), ninguno de los dos puede prometer nada sobre el otro.
Existe un protocolo para coordinar varias bases de datos en una sola transacción: el two-phase commit (2PC, "confirmación en dos fases"). Un coordinador pregunta a todos los participantes "¿podéis confirmar?" (fase 1: prepare); si todos dicen sí, ordena "confirmad" (fase 2: commit); si alguno dice no, ordena "abortad". Suena perfecto, y en microservicios se descarta casi siempre por estas razones:
| Problema del 2PC | Por qué es grave en microservicios |
|---|---|
| Bloqueos largos. Entre la fase 1 y la 2, cada participante mantiene sus filas bloqueadas esperando al coordinador. | Es exactamente el problema de la pasarela dentro de la transacción de crearPedido, pero multiplicado por la red: las filas de stock quedarían bloqueadas mientras Pagos habla con la pasarela. |
| El coordinador es un punto único de fallo. Si cae entre fases, los participantes quedan "en duda", bloqueados hasta que vuelva. | Viola el diseño para el fallo: una caída del coordinador paraliza inventario, pedidos y pagos a la vez. |
| Acoplamiento temporal total. Todos deben estar disponibles en el mismo instante. | La disponibilidad del conjunto es el producto de las disponibilidades. |
| Soporte desigual. No todos los almacenes lo soportan (MongoDB no participa en 2PC con PostgreSQL), ni los sistemas externos (la pasarela de pago no va a "preparar" un cobro). | El flujo de TechCorp incluye una pasarela externa: 2PC es imposible en el paso más delicado. |
| Escala mal. El rendimiento cae con el número de participantes y la latencia entre ellos. | Contradice el motivo de escalado por el que se adoptan microservicios. |
La conclusión de la industria, y de TechCorp: entre servicios no hay atomicidad; hay secuencias de transacciones locales, cada una atómica dentro de su servicio, coordinadas por mensajes, y con acciones de compensación para deshacer lo hecho cuando un paso posterior falla. Eso es una saga.
- El teorema CAP y la consistencia eventual, en términos prácticos
El teorema CAP (Brewer) dice que un sistema distribuido, ante una partición de red (P: dos partes no pueden comunicarse), debe elegir entre consistencia (C: todos ven el mismo dato en el mismo instante) y disponibilidad (A: todos reciben respuesta). Como las particiones ocurren (falacia 1 de 01-02: la red no es fiable), la elección real es "cuando la red falle, ¿prefiero responder con datos posiblemente desactualizados o prefiero no responder?".
Para el flujo de pedido de TechCorp, la respuesta es matizada:
- Dentro de cada servicio, consistencia fuerte:
servicio-inventariono reservará jamás más de lo que hay (reservado <= cantidades una restricción de su base de datos). - Entre servicios, consistencia eventual: durante unos segundos, el pedido está PENDIENTE en Pedidos mientras Inventario ya ha reservado; la web puede mostrar "procesando" y el cliente recibirá el correo cuando todo converja. Es aceptable porque el negocio lo tolera: nadie necesita saber en el mismo milisegundo que el stock y el pedido están de acuerdo.
"Eventual" significa "garantizado, pero no instantáneo": si dejan de llegar cambios, todas las copias acaban coincidiendo. No significa "a veces": un sistema que pierde eventos no es eventualmente consistente, es incorrecto. Los apartados 7 y 8 (outbox e idempotencia) son precisamente lo que convierte "eventual" en "garantizado".
Lo que cambia para el equipo, dicho sin teoría: entre que se crea el pedido y se confirma pueden pasar segundos, y en ese intervalo el sistema está en un estado intermedio legítimo que hay que modelar, mostrar y saber deshacer. En el monolito ese intervalo no existía para nadie externo; ahora forma parte del diseño.
- El patrón saga: coreografía frente a orquestación
Una saga es una secuencia de transacciones locales T1, T2, ..., Tn, cada una en un servicio distinto, donde cada Ti publica un mensaje que dispara Ti+1. Si Ti falla, se ejecutan las transacciones compensatorias Ci-1, ..., C1 que deshacen semánticamente lo anterior. "Semánticamente" es importante: no es un ROLLBACK (el cobro ya se hizo), es una acción de negocio inversa (un reembolso).
Hay dos formas de coordinar la secuencia:
| Aspecto | Coreografía | Orquestación |
|---|---|---|
| Quién decide el siguiente paso | Nadie central: cada servicio reacciona a los eventos que le interesan y publica los suyos. | Un orquestador (un componente dentro de un servicio, o un servicio dedicado) envía comandos a cada participante y espera sus respuestas. |
| Estilo de mensajes | Eventos ("ha ocurrido X"): pedido.creado, stock.reservado. |
Comandos ("haz X"): reservarStock, cobrar, más respuestas. |
| Dónde está la lógica del flujo | Repartida: Inventario sabe que ante pedido.creado reserva; Pagos sabe que ante stock.reservado cobra. |
Concentrada en el orquestador, que conoce todos los pasos y compensaciones. |
| Acoplamiento | Bajo: los servicios no se conocen entre sí, solo conocen eventos. | El orquestador conoce a todos; los participantes no se conocen entre sí. |
| Visibilidad del estado global | Difícil: hay que reconstruirlo a partir de los eventos (trazas, 06-02). | Fácil: el orquestador tiene el estado de cada saga en su tabla. |
| Riesgo | Con muchos pasos, nadie entiende el flujo completo ("¿quién reacciona a qué?"); dependencias cíclicas ocultas. | El orquestador se convierte en un "cerebro" central que acumula lógica de otros contextos (vuelve crearPedido con otro nombre) si no se disciplina. |
| Añadir un paso | Añadir un suscriptor; no se toca a nadie más. | Modificar el orquestador. |
| Compensaciones | Cada servicio se suscribe al evento de fallo/cancelación y compensa lo suyo. | El orquestador las invoca en orden inverso. |
| Cuándo encaja | Flujos cortos (3-5 pasos), lineales, con equipos autónomos. | Flujos largos, con bifurcaciones, plazos, intervención humana, o cuando hace falta ver "en qué paso está el pedido 88213" desde un solo sitio. |
Ninguna es "la buena". La regla práctica: coreografía por defecto para flujos simples; orquestación cuando el flujo crece o cuando la visibilidad se vuelve un problema.
- La saga "crear pedido" de TechCorp
Diseñamos la saga con los eventos del curso. Los cinco eventos principales ya son conocidos desde 01-05; el event storming de 02-02 destapó los de fallo y compensación, que ahora nombramos con la misma convención <contexto>.<hecho>:
| Evento | Publica | Significado | Papel en la saga |
|---|---|---|---|
pedido.creado |
Pedidos | Pedido registrado en PENDIENTE con líneas, total y datos de contacto/envío. | Arranque (T1). |
stock.reservado |
Inventario | Reserva creada para el pedido. | T2 correcta. |
stock.rechazado |
Inventario | No hay stock suficiente para alguna línea. | T2 fallida. |
pago.confirmado |
Pagos | La pasarela ha aceptado el cobro. | T3 correcta. |
pago.rechazado |
Pagos | La pasarela ha rechazado el cobro. | T3 fallida. |
pedido.confirmado |
Pedidos | Pedido completo (stock y pago correctos). | Fin feliz (T4). |
pedido.cancelado |
Pedidos | El pedido no se completará; lleva motivo. |
Dispara compensaciones. |
stock.liberado |
Inventario | Reserva liberada. | Compensación C2. |
pago.reembolsado |
Pagos | Cobro devuelto. | Compensación C3 (solo si llegó a cobrarse). |
Los pasos, en el camino feliz y en los dos caminos de fallo:
sequenceDiagram
autonumber
actor C as Cliente
participant GW as API Gateway :8080
participant PED as servicio-pedidos
participant INV as servicio-inventario
participant PAG as servicio-pagos
participant NOT as servicio-notificaciones
C->>GW: POST /pedidos {clienteId, lineas} + Idempotency-Key
GW->>PED: POST /pedidos
Note over PED: T1: valida cliente y precios (Clientes, Catálogo)<br/>INSERT pedido PENDIENTE + outbox(pedido.creado)
PED-->>C: 202 Accepted {pedidoId: ped-88213, estado: PENDIENTE}
PED--)INV: pedido.creado
alt hay stock
Note over INV: T2: INSERT reserva ACTIVA, UPDATE stock.reservado
INV--)PAG: stock.reservado
INV--)PED: stock.reservado
Note over PED: estado = STOCK_RESERVADO
alt cobro aceptado
Note over PAG: T3: cobrar en pasarela, INSERT pago CAPTURADO
PAG--)PED: pago.confirmado
Note over PED: T4: estado = PAGADO -> CONFIRMADO
PED--)INV: pedido.confirmado
PED--)NOT: pedido.confirmado
Note over INV: reserva CONSUMIDA, stock.cantidad -= reservado
NOT->>C: correo "pedido confirmado"
else cobro rechazado
PAG--)PED: pago.rechazado
Note over PED: estado = CANCELADO (motivo PAGO_RECHAZADO)
PED--)INV: pedido.cancelado
PED--)NOT: pedido.cancelado
Note over INV: C2: reserva LIBERADA, stock.reservado -= cantidad
INV--)PED: stock.liberado
NOT->>C: correo "no hemos podido cobrar"
end
else sin stock
INV--)PED: stock.rechazado
Note over PED: estado = CANCELADO (motivo SIN_STOCK)
PED--)NOT: pedido.cancelado
NOT->>C: correo "producto agotado"
end
Observaciones de diseño, una por una:
- La respuesta al cliente es
202 Acceptedcon estado PENDIENTE, no201con CONFIRMADO como hacía el monolito. El pedido existe, pero no está completo; el cliente consultaGET /pedidos/ped-88213(o recibe una notificación) para ver la confirmación. Es el estado intermedio legítimo del apartado 2, hecho visible en el contrato. (Comparado con la respuesta de 01-01, dondePOST /pedidosdevolvía201con PENDIENTE: ambas son válidas; el curso adopta202a partir de aquí para subrayar que el procesamiento continúa. El diseño concreto de la API se cierra en 03-01.) - T1 conserva las validaciones síncronas con Clientes y Catálogo (existencia del cliente, precios actuales): sin ellas no hay pedido que crear. Lo que sale de la petición HTTP es todo lo demás.
- Pagos reacciona a
stock.reservado, no apedido.creado. Cobrar antes de saber si hay stock obligaría a reembolsar con frecuencia; reservar primero y cobrar después minimiza compensaciones caras. El orden de la saga se elige poniendo primero los pasos más probables de fallar y más baratos de compensar. pedido.confirmadotiene dos consumidores: Notificaciones (correo) e Inventario (convertir la reserva en descuento definitivo de stock). En el monolito ese descuento estaba dentro de la transacción; ahora es la última transacción local de Inventario.- Las compensaciones son eventos de negocio, no comandos "deshaz": Inventario libera stock porque el pedido se canceló, y publica
stock.liberado; Pagos reembolsaría (pago.reembolsado) solo si el pedido se cancela después depago.confirmado, algo que en este flujo básico ocurre si Pedidos, ya PAGADO, no pudiera confirmar (por ejemplo, por una regla antifraude posterior) o si el cliente cancela en el plazo permitido. Es una rama que el equipo diseña aunque hoy sea rara. - El evento lleva lo que el consumidor necesita.
pedido.creadoincluye líneas (conproductoId,cantidad,precioUnitario,nombre), total,email,nombredel cliente ydireccionEnvio; así Inventario reserva sin consultar a nadie, Pagos cobra sin consultar a nadie y Notificaciones escribe el correo sin consultar a nadie (patrón conformist de 02-03). Ejemplo:
{
"eventoId": "evt-01J5X8Q7ZK3M",
"tipo": "pedido.creado",
"version": 1,
"ocurridoEn": "2026-08-15T10:42:00Z",
"pedidoId": "ped-88213",
"clienteId": "c-1024",
"cliente": { "email": "[email protected]", "nombre": "Ana" },
"direccionEnvio": { "calle": "Gran Vía 12", "cp": "28013", "ciudad": "Madrid" },
"lineas": [
{ "productoId": "p-501", "nombre": "Auriculares BT X200", "precioUnitario": 59.90, "cantidad": 1 },
{ "productoId": "p-777", "nombre": "Cable USB-C 2 m", "precioUnitario": 9.90, "cantidad": 2 }
],
"total": 79.70
}El eventoId único y la version no son adorno: el primero es la base de la idempotencia (apartado 8) y la segunda, del versionado de contratos (03-06). Todos los eventos del curso llevan este sobre (eventoId, tipo, version, ocurridoEn) más su carga.
- La máquina de estados del pedido
La saga se ve, desde servicio-pedidos, como una máquina de estados del agregado Pedido. Definirla explícitamente es lo que evita que un evento tardío o duplicado deje un pedido en un estado absurdo (por ejemplo, un pago.confirmado que llega para un pedido ya CANCELADO).
stateDiagram-v2
[*] --> PENDIENTE: POST /pedidos (T1)
PENDIENTE --> STOCK_RESERVADO: stock.reservado
PENDIENTE --> CANCELADO: stock.rechazado (SIN_STOCK)
STOCK_RESERVADO --> PAGADO: pago.confirmado
STOCK_RESERVADO --> CANCELADO: pago.rechazado (PAGO_RECHAZADO)
STOCK_RESERVADO --> CANCELADO: plazo agotado (TIMEOUT_PAGO)
PAGADO --> CONFIRMADO: confirmar (T4) → pedido.confirmado
PAGADO --> CANCELADO: no confirmable → reembolso
CONFIRMADO --> [*]
CANCELADO --> [*]
| Estado | Significado | Eventos que acepta | Eventos que ignora (y registra) |
|---|---|---|---|
PENDIENTE |
Creado; esperando reserva. | stock.reservado, stock.rechazado |
pago.* (aún no debería existir) |
STOCK_RESERVADO |
Stock apartado; esperando cobro. | pago.confirmado, pago.rechazado, plazo agotado |
stock.* duplicados |
PAGADO |
Cobro hecho; transitorio antes de confirmar. | confirmación interna | todo lo demás |
CONFIRMADO |
Final feliz. Publica pedido.confirmado. |
(cancelación por el cliente en plazo, extensión futura) | pago.*, stock.* |
CANCELADO |
Final. Publica pedido.cancelado con motivo. |
ninguno | todo (un pago.confirmado tardío aquí obliga a un reembolso: caso excepcional que se registra y alerta) |
PAGADO parece redundante (¿por qué no pasar de STOCK_RESERVADO a CONFIRMADO directamente?). Se mantiene separado por dos razones: permite distinguir "he registrado el pago pero aún no he publicado la confirmación" ante una caída entre ambas escrituras, y deja hueco para reglas de confirmación futuras (antifraude, validación de dirección) sin cambiar la saga.
El plazo agotado merece atención: si Pagos está caído durante media hora, los pedidos quedan en STOCK_RESERVADO con stock apartado que nadie compra. servicio-pedidos incluye un vigilante (una tarea periódica) que cancela por TIMEOUT_PAGO los pedidos que lleven más de N minutos en ese estado; y, como red de seguridad independiente, reservas.expira_en permite a Inventario liberar reservas huérfanas aunque Pedidos no lo pida. Dos mecanismos, cada uno en su contexto, para el mismo riesgo.
Esqueleto de la máquina de estados y de un consumidor de compensación (ilustrativo: sin acceso a base de datos real ni a RabbitMQ; la implementación llega en el módulo 4):
// dominio/maquinaEstadosPedido.js (servicio-pedidos) - transiciones permitidas
const TRANSICIONES = {
PENDIENTE: { 'stock.reservado': 'STOCK_RESERVADO', 'stock.rechazado': 'CANCELADO' },
STOCK_RESERVADO: { 'pago.confirmado': 'PAGADO', 'pago.rechazado': 'CANCELADO', 'timeout.pago': 'CANCELADO' },
PAGADO: { 'confirmar': 'CONFIRMADO', 'no.confirmable': 'CANCELADO' },
CONFIRMADO: {},
CANCELADO: {}
};
const MOTIVOS = { 'stock.rechazado': 'SIN_STOCK', 'pago.rechazado': 'PAGO_RECHAZADO', 'timeout.pago': 'TIMEOUT_PAGO' };
// Devuelve el nuevo estado o null si la transición no está permitida (evento tardío/duplicado).
function transicionar(estadoActual, tipoEvento) {
return TRANSICIONES[estadoActual]?.[tipoEvento] ?? null;
}
// Manejador genérico de eventos de la saga dentro de servicio-pedidos (esqueleto).
async function alRecibirEventoDeSaga(evento, repositorio, outbox) {
const pedido = await repositorio.obtener(evento.pedidoId);
const nuevoEstado = transicionar(pedido.estado, evento.tipo);
if (!nuevoEstado) { // p. ej. pago.confirmado sobre CANCELADO
registrar.aviso('transicion_ignorada', { pedidoId: pedido.pedidoId, de: pedido.estado, evento: evento.tipo });
return; // idempotente: no rompe, no repite
}
pedido.estado = nuevoEstado;
if (nuevoEstado === 'CANCELADO') pedido.motivoCancelacion = MOTIVOS[evento.tipo];
// Misma transacción local: guardar el pedido y encolar el evento saliente (outbox, apartado 7)
await repositorio.guardarConEventos(pedido, [
nuevoEstado === 'PAGADO' && { tipo: 'confirmar', pedidoId: pedido.pedidoId }, // paso interno T4
nuevoEstado === 'CONFIRMADO' && { tipo: 'pedido.confirmado', pedidoId: pedido.pedidoId, ...datosParaConsumidores(pedido) },
nuevoEstado === 'CANCELADO' && { tipo: 'pedido.cancelado', pedidoId: pedido.pedidoId, motivo: pedido.motivoCancelacion, ...datosParaConsumidores(pedido) }
].filter(Boolean));
}// consumidores/pedidoCancelado.js (servicio-inventario) - compensación C2, esqueleto
async function alPedidoCancelado(evento, reservas, outbox) {
const reserva = await reservas.buscarPorPedido(evento.pedidoId);
if (!reserva || reserva.estado !== 'ACTIVA') return; // sin reserva o ya liberada/consumida: nada que hacer (idempotente)
// Transacción local de Inventario: liberar y anunciar
await reservas.transaccion(async (tx) => {
await tx.marcarLiberada(reserva.reservaId); // UPDATE reservas SET estado='LIBERADA'
for (const linea of reserva.lineas) {
await tx.restarReservado(linea.productoId, linea.cantidad); // UPDATE stock SET reservado = reservado - cantidad
}
await outbox.encolar(tx, { tipo: 'stock.liberado', pedidoId: evento.pedidoId, reservaId: reserva.reservaId });
});
}Fíjate en que ambos esqueletos empiezan comprobando el estado y salen sin hacer nada si el evento no procede: es la mitad de la idempotencia; la otra mitad está en el apartado 8.
- La decisión de TechCorp: coreografía para empezar
Luis y su equipo eligen coreografía para la saga de pedido, por estas razones:
- El flujo es corto y lineal: cuatro transacciones, dos puntos de fallo, dos compensaciones.
- Encaja con el mapa de contextos de 02-03: Pedidos–Inventario son partnership por eventos, Pagos y Notificaciones consumen el lenguaje publicado. Nadie tiene que "mandar" sobre nadie.
- Refuerza la autonomía: añadir un consumidor (por ejemplo, un futuro
servicio-promocionesque escuchepedido.confirmadopara consumir un cupón) no toca a nadie. - El equipo aprende con lo más sencillo antes de añadir una pieza (el orquestador) que hay que operar y monitorizar.
Y fijan por escrito cuándo pasarán a orquestación, para no descubrirlo en un incidente:
| Señal | Qué indica |
|---|---|
| La saga supera los 5-6 pasos o gana bifurcaciones (envío parcial, pago fraccionado, devoluciones). | La lógica repartida deja de caber en la cabeza. |
| Nadie sabe responder rápido "¿en qué paso está el pedido X y por qué?" sin leer trazas de cuatro servicios. | Falta visibilidad; un orquestador con su tabla de sagas la da. |
| Aparecen dependencias cíclicas de eventos entre servicios. | La coreografía se ha enredado. |
| Hace falta intervención humana o plazos complejos en medio del flujo. | Los orquestadores (o motores de workflow) modelan eso mejor. |
Si llega el momento, el orquestador vivirá dentro de servicio-pedidos (es el dueño del ciclo de vida del pedido) y enviará comandos a Inventario y Pagos; los eventos públicos (pedido.confirmado, pedido.cancelado) se mantendrán para Notificaciones y para quien venga después. La máquina de estados del apartado 5 es la misma en ambos casos: por eso se define ya.
- El patrón outbox transaccional
Hay un fallo sutil que rompería toda la saga si no se trata. servicio-pedidos debe hacer dos cosas al crear un pedido: guardar la fila en su PostgreSQL y publicar pedido.creado en RabbitMQ. Son dos sistemas distintos, luego no hay transacción que abarque ambos, y las dos ordenaciones fallan:
- Guardar y luego publicar: si el proceso cae entre ambas, el pedido existe pero nadie lo sabe: se queda PENDIENTE para siempre.
- Publicar y luego guardar: si falla el guardado, Inventario reserva stock para un pedido que no existe.
Es el mismo problema del dual write de 02-04, en versión mensajería. La solución es el outbox transaccional ("bandeja de salida"):
- En la misma transacción local en la que se guarda el pedido, se inserta el evento en una tabla
outboxde la misma base de datos. O se guardan los dos, o ninguno: eso sí lo garantiza PostgreSQL. - Un componente aparte (el relay) lee la tabla
outbox, publica los eventos pendientes en el broker y los marca como publicados. Si el relay cae, al volver sigue donde estaba: ningún evento se pierde. - Como el relay puede publicar un evento dos veces (por ejemplo, publica y cae antes de marcar), la entrega es al menos una vez, y los consumidores deben ser idempotentes (apartado 8).
flowchart LR
API[API de servicio-pedidos] -- "1. BEGIN<br/>INSERT pedidos<br/>INSERT outbox<br/>COMMIT" --> BD[(PostgreSQL pedidos)]
RELAY[Relay de outbox<br/>en el propio servicio] -- "2. SELECT ... WHERE publicado_en IS NULL" --> BD
RELAY -- "3. publicar" --> MQ[(RabbitMQ)]
RELAY -- "4. UPDATE outbox SET publicado_en = NOW()" --> BD
MQ -. pedido.creado .-> INV[servicio-inventario]
-- Tabla outbox en la base de datos de CADA servicio que publica eventos (pedidos, inventario, pagos, clientes, catálogo)
CREATE TABLE outbox (
evento_id TEXT PRIMARY KEY, -- 'evt-01J5X8Q7ZK3M', viaja en el sobre del evento
agregado_tipo TEXT NOT NULL, -- 'Pedido'
agregado_id TEXT NOT NULL, -- 'ped-88213' (permite publicar en orden por agregado)
tipo TEXT NOT NULL, -- 'pedido.creado'
version INT NOT NULL DEFAULT 1,
carga JSONB NOT NULL, -- el cuerpo del evento
creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
publicado_en TIMESTAMPTZ -- NULL = pendiente de publicar
);
CREATE INDEX idx_outbox_pendientes ON outbox (creado_en) WHERE publicado_en IS NULL;Y el uso, en pseudocódigo, desde el repositorio de Pedidos (es el guardarConEventos que apareció en el apartado 5):
// repositorio/PedidoRepositorio.js (servicio-pedidos) - esqueleto del guardado con outbox
async function guardarConEventos(pedido, eventos) {
await bd.transaccion(async (tx) => { // UNA transacción local
await tx.upsertPedido(pedido); // pedidos + lineas_pedido
for (const evento of eventos) {
await tx.insertar('outbox', {
evento_id: generarId('evt'), agregado_tipo: 'Pedido', agregado_id: pedido.pedidoId,
tipo: evento.tipo, carga: evento
});
}
}); // COMMIT: pedido y eventos, o nada
// El relay, en otro hilo/proceso del mismo servicio, se encarga de publicar. Aquí no se toca el broker.
}Dos precisiones. Primera: el relay puede ser una tarea periódica del propio servicio (polling de la tabla cada pocos cientos de milisegundos) o una herramienta de captura de cambios (CDC); TechCorp empieza con polling, que es suficiente para 3.000 pedidos/día, y el detalle de implementación se ve en 04-04. Segunda: la tabla outbox es también un registro de auditoría gratuito de todo lo que el servicio ha comunicado al exterior, algo que resultará valioso en 06-03.
- Idempotencia de los consumidores y claves de idempotencia
Con entrega "al menos una vez", todo consumidor recibirá algún evento repetido tarde o temprano (reintento del relay, reentrega del broker tras un fallo de confirmación, redespliegue en mitad de un procesamiento). Idempotente significa que procesar el mismo evento dos veces produce el mismo resultado que procesarlo una: Inventario no reserva dos veces, Pagos no cobra dos veces, Notificaciones no envía dos correos.
Hay dos mecanismos complementarios, y el diseño de TechCorp usa ambos:
a) Idempotencia natural por el modelo. Diseñar las escrituras de forma que la repetición no cambie nada:
reservas.pedido_id UNIQUE: un segundopedido.creadopara el mismo pedido choca con la restricción y el consumidor lo trata como "ya hecho".pagos.pedido_id UNIQUE: un segundostock.reservadono produce un segundo cobro.- La máquina de estados: un segundo
pago.confirmadosobre un pedido ya PAGADO/CONFIRMADO no tiene transición y se ignora.
b) Registro de eventos procesados. Para consumidores cuyo efecto no es una fila única (enviar un correo, llamar a la pasarela), se guarda el eventoId en la misma transacción que el efecto:
-- En la base de datos de cada consumidor
CREATE TABLE eventos_procesados (
evento_id TEXT PRIMARY KEY,
consumidor TEXT NOT NULL, -- 'notificaciones.correoConfirmacion'
procesado_en TIMESTAMPTZ NOT NULL DEFAULT NOW()
);// Envoltorio genérico de idempotencia para un consumidor (esqueleto)
async function procesarUnaVez(evento, consumidor, bd, manejador) {
return bd.transaccion(async (tx) => {
const yaVisto = await tx.existe('eventos_procesados', { evento_id: evento.eventoId, consumidor });
if (yaVisto) return 'DUPLICADO'; // no se repite el efecto
await manejador(evento, tx); // el efecto real, dentro de la transacción
await tx.insertar('eventos_procesados', { evento_id: evento.eventoId, consumidor });
return 'PROCESADO';
});
}Cuando el efecto es externo (llamar a la pasarela de pago) no se puede meter en la transacción; en ese caso se registra antes un intento con estado "en curso" y se usa la clave de idempotencia de la propia pasarela (todas las pasarelas serias aceptan una, precisamente por esto): pasarela.cobrar({ importe, claveIdempotencia: pedidoId }). Si se repite la llamada, la pasarela devuelve el mismo resultado sin cobrar de nuevo.
c) La clave de idempotencia en la API. El sexto problema de crearPedido en 01-05 era el "doble clic": dos POST /pedidos iguales, dos pedidos, dos cobros. La solución de diseño es que el cliente envíe una cabecera Idempotency-Key (un UUID generado por el navegador para ese intento de compra) y que servicio-pedidos guarde, en su transacción de T1, la pareja clave → pedidoId y respuesta; ante una repetición con la misma clave, devuelve la misma respuesta sin crear nada. La forma exacta de la cabecera y de la respuesta se cierra en 03-01; el diseño de datos es una tabla más en pedidos:
CREATE TABLE claves_idempotencia (
clave TEXT PRIMARY KEY, -- valor de la cabecera Idempotency-Key
pedido_id TEXT NOT NULL,
respuesta JSONB NOT NULL, -- lo que se devolvió la primera vez
creada_en TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Con outbox (los eventos salen seguro) e idempotencia (los eventos repetidos no hacen daño), la consistencia eventual pasa de "esperemos que llegue" a "garantizado".
- CQRS: separar el modelo de escritura del de lectura
CQRS (Command Query Responsibility Segregation) es la idea de usar modelos distintos para escribir y para leer. En 02-04 apareció como "vista materializada" y como respuesta al panel de administración con filtros cruzados; ahora tiene nombre y mecánica.
- El modelo de escritura es el agregado Pedido con sus invariantes y su máquina de estados, en las tablas
pedidosylineas_pedido. Está optimizado para decidir (¿puedo confirmar?, ¿puedo cancelar?). - El modelo de lectura es una o varias tablas desnormalizadas, construidas por un proyector que consume eventos, y optimizadas para mostrar: sin
JOIN, con exactamente las columnas que necesita cada pantalla.
Para "pedidos con nombre de cliente y de producto" del panel de administración:
-- Modelo de lectura, mantenido SOLO por el proyector de eventos. Nunca lo escribe la API de comandos.
CREATE TABLE vista_pedidos_admin (
pedido_id TEXT PRIMARY KEY,
cliente_id TEXT NOT NULL,
cliente_nombre TEXT NOT NULL, -- de pedido.creado (y actualizado por cliente.actualizado)
ciudad_envio TEXT NOT NULL, -- de pedido.creado
productos_texto TEXT NOT NULL, -- 'Auriculares BT X200 x1, Cable USB-C 2 m x2'
total NUMERIC(10,2) NOT NULL,
estado TEXT NOT NULL, -- actualizado por pedido.confirmado / pedido.cancelado
creado_en TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_vista_pedidos_admin_dia_ciudad ON vista_pedidos_admin (creado_en, ciudad_envio);// proyectores/vistaPedidosAdmin.js - esqueleto del proyector (consumidor idempotente de eventos)
const manejadores = {
'pedido.creado': (e, tx) => tx.upsert('vista_pedidos_admin', {
pedido_id: e.pedidoId, cliente_id: e.clienteId, cliente_nombre: e.cliente.nombre,
ciudad_envio: e.direccionEnvio.ciudad, total: e.total, estado: 'PENDIENTE',
productos_texto: e.lineas.map(l => `${l.nombre} x${l.cantidad}`).join(', '),
creado_en: e.ocurridoEn }),
'pedido.confirmado': (e, tx) => tx.actualizar('vista_pedidos_admin', { pedido_id: e.pedidoId }, { estado: 'CONFIRMADO' }),
'pedido.cancelado': (e, tx) => tx.actualizar('vista_pedidos_admin', { pedido_id: e.pedidoId }, { estado: 'CANCELADO' }),
'cliente.actualizado':(e, tx) => tx.actualizar('vista_pedidos_admin', { cliente_id: e.clienteId }, { cliente_nombre: e.nombre })
};
// Se ejecuta envuelto en procesarUnaVez(...) del apartado 8.Dónde vive: TechCorp empieza con la vista dentro de servicio-pedidos (misma base de datos, tabla distinta, alimentada por sus propios eventos y por cliente.actualizado). Es CQRS "ligero": no hace falta un servicio de consultas separado hasta que el volumen o los consumidores lo justifiquen. Si el día de mañana el panel necesita cruzar datos de cinco contextos, la vista se mueve a un servicio de consultas o al almacén analítico de 02-04.
Cuándo compensa CQRS y cuándo no:
| Compensa | No compensa |
|---|---|
| Consultas que cruzan contextos, con filtros y paginación (paneles, listados). | El detalle de un pedido: el modelo de escritura ya lo devuelve bien. |
| Lecturas masivas que no deben cargar el modelo transaccional (búsqueda de pedidos, informes). | Sistemas pequeños con una sola base de datos, donde un JOIN resuelve. |
| Cuando el modelo de escritura es rico (agregado con invariantes) y el de lectura es plano. | Cuando añadir un proyector solo introduce retraso sin quitar un problema real. |
CQRS trae consistencia eventual entre escritura y lectura: un pedido recién creado tarda milisegundos o segundos en aparecer en la vista. Para el panel de administración es irrelevante; para "acabo de crear el pedido y quiero verlo" hay que leer del modelo de escritura o devolver la representación en la respuesta del comando.
- Event sourcing: guardar los hechos en lugar del estado
Event sourcing lleva la idea un paso más allá: no se guarda el estado actual del agregado, sino la secuencia de eventos que lo produjeron, y el estado se reconstruye reproduciéndolos. La tabla pedidos con su columna estado desaparecería; en su lugar habría un event store:
| secuencia | agregado_id | tipo | carga |
|---|---|---|---|
| 1 | ped-88213 | PedidoCreado | líneas, total, cliente... |
| 2 | ped-88213 | StockReservado | reservaId |
| 3 | ped-88213 | PagoRegistrado | pagoId, importe |
| 4 | ped-88213 | PedidoConfirmado | — |
// Reconstrucción del pedido a partir de sus eventos (esqueleto ilustrativo)
function reconstruirPedido(eventos) {
return eventos.reduce((pedido, e) => {
switch (e.tipo) {
case 'PedidoCreado': return { ...e.carga, estado: 'PENDIENTE' };
case 'StockReservado': return { ...pedido, estado: 'STOCK_RESERVADO', reservaId: e.carga.reservaId };
case 'PagoRegistrado': return { ...pedido, estado: 'PAGADO', pagoId: e.carga.pagoId };
case 'PedidoConfirmado': return { ...pedido, estado: 'CONFIRMADO' };
case 'PedidoCancelado': return { ...pedido, estado: 'CANCELADO', motivo: e.carga.motivo };
default: return pedido;
}
}, null);
}| A favor | En contra |
|---|---|
| Auditoría completa por construcción: se sabe qué pasó, cuándo y en qué orden. | Complejidad: reconstruir estado, instantáneas (snapshots) cuando hay muchos eventos, versionado de eventos antiguos que ya no tienen la misma forma. |
| Consultas temporales: "¿cómo estaba este pedido el martes a las 10?". | CQRS obligatorio: no se puede hacer WHERE estado = 'PENDIENTE' sobre un event store; hacen falta proyecciones para cualquier consulta. |
| Los eventos ya son la fuente de verdad: outbox y event store convergen. | Curva de aprendizaje alta y herramientas menos maduras en el ecosistema habitual. |
| Encaja en dominios donde el histórico es el negocio (contabilidad, banca, seguros). | Corregir un dato erróneo no es un UPDATE: es emitir un evento de corrección. |
Decisión de TechCorp: no, por ahora. Justificación:
- El estado del pedido es sencillo (cinco estados, dos ramas) y el modelo relacional de 02-04 lo representa sin esfuerzo.
- Las necesidades de auditoría (saber por qué se canceló un pedido, cuándo se cobró) se cubren con
motivo_cancelacion, con la tablaoutbox(que ya conserva todo lo comunicado) y con una tablahistorial_estados_pedidosi hiciera falta, a una fracción del coste. - El equipo está aprendiendo a la vez sagas, outbox, idempotencia, Docker, Kubernetes y observabilidad. Añadir event sourcing multiplicaría el riesgo de la migración sin resolver ninguno de los cinco problemas de 01-05.
- Se puede adoptar más adelante en un solo contexto (Pedidos o Pagos, si un requisito regulatorio lo exige) sin tocar a los demás, gracias precisamente a que cada servicio es dueño de su almacenamiento.
Es una decisión típica de arquitectura: no se descarta la técnica, se descarta ahora y se deja escrita la señal que la reabriría.
Errores Comunes y Consejos
- Intentar recuperar el 2PC "con un poco de código": bloquear stock mientras se llama a Pagos por HTTP y esperar la respuesta. Es la transacción de
crearPedidocon más latencia y más fallos. Si un paso puede fallar, diseña su compensación. - Compensaciones incompletas. Cada paso que modifica algo necesita su inversa diseñada antes de desplegar: reservar ↔ liberar, cobrar ↔ reembolsar, confirmar ↔ (no hay: por eso es el último).
- Publicar el evento fuera de la transacción. Sin outbox, se pierden eventos, y perder un
pedido.creadoes un pedido zombi. La outbox no es opcional. - Suponer que un evento llega una sola vez y en orden. Llegará repetido y, a veces, desordenado (un
pago.confirmadopuede alcanzar a Pedidos antes que elstock.reservadosi el consumidor iba lento). La máquina de estados y el registro de eventos procesados protegen de ambas cosas. - Orquestador que sabe demasiado. Si algún día se pasa a orquestación, el orquestador manda comandos y espera respuestas; no calcula stock ni decide cobros. Eso es de cada contexto.
- CQRS y event sourcing "porque son lo moderno". CQRS solo donde una consulta lo pida; event sourcing solo donde el histórico sea el negocio.
- Consejo: dibuja siempre la saga como diagrama de secuencia y como máquina de estados. El primero enseña el camino feliz; la segunda es la que te obliga a pensar en los eventos tardíos, duplicados y fuera de orden.
Ejercicios
Ejercicio 1: Un evento fuera de orden
Por un reinicio del consumidor, servicio-pedidos recibe pago.confirmado de ped-88213 antes que stock.reservado (ambos existen y son válidos). Con la máquina de estados del apartado 5: ¿qué ocurre con cada evento?, ¿queda el pedido en un estado correcto al final?, ¿qué habría que cambiar en el diseño si esa situación fuese frecuente?
Ejercicio 2: Diseñar una compensación nueva
TechCorp permitirá que el cliente cancele un pedido CONFIRMADO durante los 30 minutos siguientes. Diseña la ampliación de la saga: qué transición se añade a la máquina de estados, qué evento publica Pedidos, qué hace cada consumidor (Inventario, Pagos, Notificaciones), y qué garantiza que un doble clic en "cancelar" no reembolsa dos veces.
Ejercicio 3: ¿Outbox o no?
servicio-catalogo publica producto.actualizado cada vez que un operador guarda una ficha en MongoDB. Un compañero propone publicar directamente en RabbitMQ después de guardar, "porque MongoDB no es PostgreSQL y no tenemos outbox". Explica qué puede fallar, cómo aplicarías el patrón outbox con MongoDB (pista: una colección outbox y transacciones de MongoDB, o el propio documento) y qué consumidor de TechCorp sufriría si se perdiera un producto.actualizado.
Soluciones
Ejercicio 1
El pedido está en PENDIENTE. Llega pago.confirmado: en PENDIENTE no hay transición para ese evento → se ignora y se registra un aviso (transicion_ignorada). Luego llega stock.reservado: PENDIENTE → STOCK_RESERVADO, correcto. Pero el pago.confirmado ya se descartó, así que el pedido se quedaría en STOCK_RESERVADO hasta que el vigilante lo cancele por TIMEOUT_PAGO, y Pagos habría cobrado: acabaría en CANCELADO con un pago.confirmado "huérfano" que exige reembolso. Es correcto en el sentido de que no hay estado imposible, pero es un mal resultado de negocio. Si fuera frecuente, hay dos mejoras: (a) en lugar de descartar el evento no aplicable, aparcarlo (guardarlo como "pendiente de aplicar" y reintentarlo cuando cambie el estado, o pedir al broker que lo reentregue más tarde); (b) hacer que Pagos incluya en pago.confirmado la reservaId, de modo que Pedidos pueda aceptar PENDIENTE → PAGADO sabiendo que la reserva existe. En la práctica el desorden es raro porque pago.confirmado es causalmente posterior a stock.reservado, y (a) basta.
Ejercicio 2
- Transición nueva:
CONFIRMADO → CANCELADOcon evento de entradacancelacion.solicitada(comando del cliente víaPOST /pedidos/{id}/cancelacion), permitida solo siNOW() - confirmado_en <= 30 miny el pedido no ha salido de almacén (regla del contexto Pedidos; en un diseño más completo, "enviado" sería otro estado que bloquearía la cancelación). - Pedidos publica
pedido.canceladoconmotivo: 'CANCELADO_POR_CLIENTE'y, en la carga,pagoIdopedidoIdpara que Pagos localice el cobro. - Inventario: la reserva ya está
CONSUMIDA(el stock se descontó al confirmar); su compensación es reponer cantidad (cantidad += n) y publicarstock.liberado(o unstock.repuesto, si se quiere distinguir). - Pagos: busca el pago
CAPTURADOdel pedido; si existe, reembolsa en la pasarela con clave de idempotenciareembolso-<pedidoId>y publicapago.reembolsado; si no existe cobro, no hace nada. - Notificaciones: correo "pedido cancelado, reembolso en curso".
- Doble clic: la segunda
POST .../cancelacionencuentra el pedido yaCANCELADO(sin transición → 409 o respuesta idempotente); aunque llegase un segundopedido.cancelado, Pagos lo detecta poreventos_procesadosy por el estadoREEMBOLSADOde su fila; y la pasarela lo detiene por la clave de idempotencia. Tres capas.
Ejercicio 3
Puede fallar exactamente lo mismo que en PostgreSQL: el proceso cae entre guardar el documento y publicar (evento perdido: la réplica de precios de quien lo consuma queda desactualizada) o publica y luego falla el guardado (evento fantasma). El patrón es el mismo con MongoDB: (a) usar una transacción multi-documento de MongoDB (disponible en replica sets) para escribir el documento de productos y un documento en la colección outbox atómicamente, con un relay que lea outbox y publique; o (b) escribir los eventos pendientes dentro del propio documento de producto (eventosPendientes: [...]) en la misma operación de escritura, y que el relay los extraiga y los vacíe (variante "outbox embebida", útil sin transacciones); o (c) usar change streams de MongoDB como mecanismo de CDC. Quién sufriría: cualquier consumidor que mantenga una réplica de datos de catálogo (por ejemplo, la vista materializada del panel si mostrase nombres actualizados, o el almacén analítico de 02-04 para categorías). servicio-pedidos no sufre al crear pedidos porque consulta el precio por composición síncrona; ese fue justamente el motivo de esa decisión en 02-04.
Conclusión
Hemos sustituido la transacción única de crearPedido por un diseño distribuido completo. Sabemos por qué no hay ACID entre servicios y por qué el 2PC se descarta (bloqueos, coordinador único, sistemas externos), qué significa en la práctica la consistencia eventual (estados intermedios legítimos, garantizados pero no instantáneos), y hemos diseñado la saga "crear pedido" de TechCorp por coreografía: pedido.creado → stock.reservado → pago.confirmado → pedido.confirmado, con los eventos de fallo stock.rechazado y pago.rechazado, el evento pedido.cancelado (con motivo SIN_STOCK, PAGO_RECHAZADO o TIMEOUT_PAGO) y las compensaciones stock.liberado y pago.reembolsado. La máquina de estados PENDIENTE → STOCK_RESERVADO → PAGADO → CONFIRMADO / CANCELADO gobierna qué evento se acepta y cuál se ignora; el outbox transaccional garantiza que un evento sale si y solo si el cambio se guardó; la idempotencia (restricciones UNIQUE, tabla eventos_procesados, cabecera Idempotency-Key, claves de la pasarela) hace inofensivas las repeticiones. Con CQRS hemos añadido la vista vista_pedidos_admin alimentada por un proyector, y hemos decidido que event sourcing no compensa hoy para TechCorp, dejando escritas las señales que reabrirían la decisión (igual que las que llevarían de coreografía a orquestación).
Con esto termina el módulo de diseño: tenemos principios, un plan de descomposición con orden de extracción, seis bounded contexts con su mapa de relaciones, una base de datos por servicio con su esquema, y una saga con outbox e idempotencia. Lo que aún no hemos decidido es cómo hablan exactamente los servicios: la forma de las APIs REST de cada uno (POST /pedidos con su 202, GET /productos?ids=, POST /reservas), cómo se publican y consumen de verdad los eventos en RabbitMQ, cuándo conviene gRPC o GraphQL, qué hace el API Gateway del puerto 8080, cómo se encuentran los servicios entre sí y cómo se versionan los contratos sin romper a nadie. Ese es el módulo 3, y empieza por las APIs RESTful.
Curso de Microservicios
Módulo 1: Introducción a los Microservicios
- Conceptos Básicos de Microservicios
- Ventajas y Desventajas de los Microservicios
- Comparación con la Arquitectura Monolítica
- Cuándo Adoptar Microservicios: Criterios de Decisión
- El Caso Práctico del Curso: la Tienda Online de TechCorp
Módulo 2: Diseño de Microservicios
- Principios de Diseño de Microservicios
- Descomposición de Aplicaciones Monolíticas
- Definición de Bounded Contexts
- Gestión de Datos: una Base de Datos por Servicio
- Consistencia Distribuida: Sagas, CQRS y Event Sourcing
Módulo 3: Comunicación entre Microservicios
- APIs RESTful
- Mensajería Asíncrona
- Protocolos de Comunicación: gRPC, GraphQL
- API Gateway y Backend for Frontend
- Descubrimiento de Servicios y Balanceo de Carga
- Contratos y Versionado de APIs
Módulo 4: Implementación de Microservicios
- Elección de Tecnologías y Herramientas
- Desarrollo de un Microservicio Simple
- Gestión de Configuración
- Integración Práctica: Consumir APIs y Publicar Eventos
- Pruebas en Microservicios: Unitarias, de Integración y de Contrato
Módulo 5: Despliegue y Orquestación
- Contenedores y Docker
- Orquestación con Kubernetes
- CI/CD para Microservicios
- Estrategias de Despliegue: Rolling, Blue-Green y Canary
- Service Mesh: Istio y Linkerd
Módulo 6: Monitoreo y Mantenimiento
- Monitoreo y Logging
- Trazabilidad Distribuida con OpenTelemetry
- Gestión de Errores y Recuperación
- Escalabilidad y Rendimiento
- SLOs, Alertas y Gestión de Incidentes
Módulo 7: Seguridad en Microservicios
- Autenticación y Autorización
- Seguridad en la Comunicación
- Prácticas de Seguridad
- Seguridad en Contenedores y Kubernetes
