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

  1. Por qué no hay transacciones ACID entre servicios
  2. El teorema CAP y la consistencia eventual, en términos prácticos
  3. El patrón saga: coreografía frente a orquestación
  4. La saga "crear pedido" de TechCorp
  5. La máquina de estados del pedido
  6. La decisión de TechCorp: coreografía para empezar
  7. El patrón outbox transaccional
  8. Idempotencia de los consumidores y claves de idempotencia
  9. CQRS: separar el modelo de escritura del de lectura
  10. Event sourcing: guardar los hechos en lugar del estado

  1. 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.

  1. 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-inventario no reservará jamás más de lo que hay (reservado <= cantidad es 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.

  1. 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.

  1. 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 Accepted con estado PENDIENTE, no 201 con CONFIRMADO como hacía el monolito. El pedido existe, pero no está completo; el cliente consulta GET /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, donde POST /pedidos devolvía 201 con PENDIENTE: ambas son válidas; el curso adopta 202 a 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 a pedido.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.confirmado tiene 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 de pago.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.creado incluye líneas (con productoId, cantidad, precioUnitario, nombre), total, email, nombre del cliente y direccionEnvio; 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.

  1. 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.

  1. 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-promociones que escuche pedido.confirmado para 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.

  1. 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"):

  1. En la misma transacción local en la que se guarda el pedido, se inserta el evento en una tabla outbox de la misma base de datos. O se guardan los dos, o ninguno: eso sí lo garantiza PostgreSQL.
  2. 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.
  3. 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.

  1. 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 segundo pedido.creado para el mismo pedido choca con la restricción y el consumidor lo trata como "ya hecho".
  • pagos.pedido_id UNIQUE: un segundo stock.reservado no produce un segundo cobro.
  • La máquina de estados: un segundo pago.confirmado sobre 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".

  1. 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 pedidos y lineas_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.

  1. 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:

  1. El estado del pedido es sencillo (cinco estados, dos ramas) y el modelo relacional de 02-04 lo representa sin esfuerzo.
  2. Las necesidades de auditoría (saber por qué se canceló un pedido, cuándo se cobró) se cubren con motivo_cancelacion, con la tabla outbox (que ya conserva todo lo comunicado) y con una tabla historial_estados_pedido si hiciera falta, a una fracción del coste.
  3. 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.
  4. 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 crearPedido con 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.creado es 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.confirmado puede alcanzar a Pedidos antes que el stock.reservado si 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 → CANCELADO con evento de entrada cancelacion.solicitada (comando del cliente vía POST /pedidos/{id}/cancelacion), permitida solo si NOW() - confirmado_en <= 30 min y 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.cancelado con motivo: 'CANCELADO_POR_CLIENTE' y, en la carga, pagoId o pedidoId para 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 publicar stock.liberado (o un stock.repuesto, si se quiere distinguir).
  • Pagos: busca el pago CAPTURADO del pedido; si existe, reembolsa en la pasarela con clave de idempotencia reembolso-<pedidoId> y publica pago.reembolsado; si no existe cobro, no hace nada.
  • Notificaciones: correo "pedido cancelado, reembolso en curso".
  • Doble clic: la segunda POST .../cancelacion encuentra el pedido ya CANCELADO (sin transición → 409 o respuesta idempotente); aunque llegase un segundo pedido.cancelado, Pagos lo detecta por eventos_procesados y por el estado REEMBOLSADO de 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.creadostock.reservadopago.confirmadopedido.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

Módulo 2: Diseño de Microservicios

Módulo 3: Comunicación entre Microservicios

Módulo 4: Implementación de Microservicios

Módulo 5: Despliegue y Orquestación

Módulo 6: Monitoreo y Mantenimiento

Módulo 7: Seguridad en Microservicios

Módulo 8: Casos de Estudio y Ejemplos Prácticos

© Copyright 2026. Todos los derechos reservados