En 04-02 escribimos servicio-catalogo, en 04-04 servicio-pedidos y entre 03-04 y 07-01 el gateway. Los otros cuatro servicios del mapa de 01-05 —Inventario, Pagos, Notificaciones y Clientes— los hemos descrito muchas veces (sus tablas en 02-04, sus eventos en 02-05 y 03-02, sus contratos en 03-01, su seguridad en el módulo 7) pero nunca los hemos escrito. Esta lección los completa. No hay técnica nueva: cada servicio usa la misma plantilla plantilla-servicio-node (04-01), los mismos módulos de @techcorp/comun-http y la misma estructura de carpetas que Pedidos, así que mostraremos completo y comentado lo que es distinto en cada uno (su consumidor y su caso de uso clave) y resumiremos en tablas lo que es igual.

Al final tendremos el sistema entero: el mapa definitivo de eventos, el diagrama de secuencia de la saga con los seis servicios reales, el recorrido del pedido ped-88213 de Ana por cada base de datos y cada cola con sus tiempos, y la tabla de pactos y pruebas E2E que cada servicio aporta. El despliegue y la operación de todo ello quedan para 08-03.

Contenido

  1. Estado de partida: lo que ya está escrito
  2. Lo común a los cuatro servicios nuevos
  3. servicio-inventario
  4. servicio-pagos
  5. servicio-notificaciones
  6. servicio-clientes
  7. Mapa final de eventos y la saga con los seis servicios
  8. El recorrido de ped-88213 por el sistema completo
  9. Pruebas: qué pactos y qué E2E añade cada servicio

  1. Estado de partida: lo que ya está escrito

Pieza Dónde se escribió Qué contiene Qué le falta para el sistema completo
servicio-catalogo (3001) 04-02 (+ Redis en 06-04, HPA, autenticar({ opcional: true }) en 07-01) crearApp({ repositorio, logger, comprobacionesSalud }), productosServicio/productosRepositorio sobre MongoDB, GET /v1/productos en tres formas, scripts/semilla.js (p-501, p-777, p-802) Nada: publica producto.actualizado (invalidación de la caché de 06-04, analítica); lo consumía solo durante la migración (08-01)
servicio-pedidos (3002) 04-04 (+ resiliencia 06-03, telemetría 06-02, autenticar/regla del propietario 07-01, auditoría 07-03) Agregado Pedido, crearPedido con Idempotency-Key, guardarConEventos, relay del outbox, consumidorSaga (pedidos.saga), consumidorClientes (pedidos.clientes), vigilanteSaga, GET /v1/pedidos/{id} con ETag Que sus cuatro colaboradores existan de verdad
Gateway (8080) 03-04, 07-01 Rutas `/api/v1/productos pedidos
bff-movil (3010) 03-03, 03-04 GraphQL sobre Catálogo, Pedidos y Clientes Solo referenciado: no lo tocamos
@techcorp/comun-http 04-01, 03-02, 06-01, 06-03, 07-01, 07-03 crearLogger, middlewareRequestId, responderProblema, middlewareErrores, crearRutasSalud, ErrorNegocio, mensajeria/{topologia,publicador,consumidor,outbox,idempotencia}, crearClienteHttp, reintentar, crearCircuitBreaker, autenticar/requiereRol/requiereScope, crearAuditoria, métricas Nada

Sobre la última fila, un recordatorio de 08-01 (fase 2): el relay del outbox y procesarUnaVez nacieron en Pedidos (04-04) y se movieron a la librería en cuanto Inventario los necesitó. En esta lección los usaremos desde @techcorp/comun-http/mensajeria/outbox (crearOutbox(bd)encolar(tx, eventos), crearRelayOutbox) y .../mensajeria/idempotencia (crearIdempotencia(bd)procesarUnaVez(eventoId, consumidor, fn)), con exactamente el código de 04-04 §5 y §7.

  1. Lo común a los cuatro servicios nuevos

Los cuatro nacen de la plantilla y comparten esta forma; en cada apartado solo escribiremos los ficheros marcados con ★.

servicio-<nombre>/
├── src/
│   ├── servidor.js  app.js  config.js  salud.js  telemetria.js      # 04-02, 04-03, 06-02: idénticos salvo nombres
│   ├── rutas/*.js                                                    # solo los endpoints de la tabla de cada servicio
│   ├── casos-uso/*.js                                     ★          # la lógica de negocio del servicio
│   ├── dominio/*.js                                                  # estados y reglas puras
│   ├── repositorios/*.js                                             # SQL del servicio; sin lógica
│   ├── clientes/*.js  (adaptadores a sistemas externos)  ★          # pasarela, correo, Keycloak: ACL (02-03)
│   ├── infra/postgres.js                                             # el de 04-04 §2, copiado (utilidad de 30 líneas, 02-02 §6)
│   └── mensajeria/consumidor<Cola>.js                     ★          # sobre crearConsumidor de la librería (06-03 §8)
├── migraciones/NNN-*.sql   scripts/migrar.js                         # 04-04 §2
├── contratos/openapi.yaml  contratos/asyncapi.yaml                   # 03-06
└── .github/workflows/ci.yml  (9 líneas: uses servicio-node-ci.yml@v1, 05-03 §11)

Y el consumidor genérico de la librería, tal como quedó en 06-03 §8, con la firma que usarán los tres consumidores de esta lección:

// @techcorp/comun-http/mensajeria/consumidor.js — firma (el cuerpo es el de 06-03 §8)
// crearConsumidor({ canal, cola, routingKeys, manejadores, idempotencia, logger, prefetch = 10, maxIntentos = 5, ttlReintentoMs = 30000 })
//   - declara <cola>, <cola>.reintento (TTL) y <cola>.dlq con declararColaConReintento
//   - por cada mensaje: parsea el sobre {eventoId, tipo, version, ocurridoEn, carga}; busca manejadores[tipo]
//   - ejecuta idempotencia.procesarUnaVez(sobre.eventoId, cola, (tx) => manejador(sobre, tx))  → duplicados no repiten efectos
//   - ack si termina; err.transitorio === true y x-intentos < maxIntentos → <cola>.reintento; si no → <cola>.dlq
//   - propaga traceparent (06-02) y requestId a los logs
// Devuelve { iniciar, parar }.

Convención de errores en los manejadores: throw Object.assign(new Error('...'), { transitorio: true }) para "vuelve a intentarlo en 30 s" (BD o dependencia caída, evento que llega antes de tiempo) y ErrorNegocio o error normal para "esto no se arreglará solo" (DLQ a la primera). Es la decisión que INC-2031 (06-05) convirtió en regla.

  1. servicio-inventario

Puerto 3006, equipo de Pedidos, PostgreSQL inventario. Es el servicio que en el monolito era un UPDATE stock dentro de crearPedido y ahora es el dueño exclusivo del invariante reservado <= cantidad (02-04 §7.2).

Responsabilidad Stock disponible, reservas con caducidad, consumo y liberación; entradas de almacén
Endpoints POST /v1/reservas (201 / 409 SIN_STOCK; Idempotency-Key), DELETE /v1/reservas/{id} (204), GET /v1/stock/{productoId} ({ productoId, disponible, reservado }), PUT /v1/stock/{productoId}/entradas (almacén, rol operador); todos internos, con autenticar + requiereRol('servicio' | 'operador')
Consume (cola inventario.pedidos) pedido.creado → reservar; pedido.confirmado → consumir; pedido.cancelado → liberar
Publica stock.reservado, stock.rechazado, stock.liberado, stock.repuesto (entradas de almacén; hoy sin consumidor)
Tablas stock, reservas, lineas_reserva (02-04 §7.2), outbox, eventos_procesados
Tareas caducarReservas cada 60 s (reservas ACTIVA con expira_en < now()), CronJob reconciliar-reservas (06-03 §9)
config.js PUERTO=3006, INVENTARIO_DB_URL (Secret inventario-db), RABBITMQ_URL (Secret inventario-rabbitmq), RESERVA_TTL_S=900, CADUCIDAD_INTERVALO_MS=60000, OUTBOX_INTERVALO_MS, PEDIDOS_URL (solo la reconciliación), LOG_NIVEL, OTEL_*, KEYCLOAK_ISSUER/AUDIENCE

El caso de uso clave es la reserva. Todo ocurre en una transacción local: bloqueo de las filas de stock, comprobación, escritura de la reserva y evento en el outbox. Es la T2 de la saga de 02-05.

// src/casos-uso/reservarStock.js
const { randomUUID } = require('node:crypto');

function crearCasoUsoReservarStock({ bd, outbox, ttlSegundos, logger }) {
  // Devuelve { estado: 'RESERVADA' | 'RECHAZADA', reservaId?, faltantes? }. Se ejecuta con el tx de procesarUnaVez
  // (consumidor) o con uno propio (POST /v1/reservas). Idempotente por pedidoId gracias a reservas.pedido_id UNIQUE.
  return async function reservarStock({ pedidoId, lineas, clienteId, total }, tx) {
    // 1. ¿Ya existe una reserva para este pedido? (reentrega, o POST repetido): devolver la misma, sin tocar stock
    const previa = (await tx.consultar('SELECT reserva_id, estado FROM reservas WHERE pedido_id = $1', [pedidoId])).rows[0];
    if (previa) return { estado: previa.estado === 'ACTIVA' || previa.estado === 'CONSUMIDA' ? 'RESERVADA' : 'RECHAZADA', reservaId: previa.reserva_id };

    // 2. Bloquear las filas de stock implicadas, SIEMPRE en el mismo orden (por producto_id) para que dos pedidos
    //    con los mismos productos no se bloqueen en cruz (deadlock). FOR UPDATE: nadie más las toca hasta el COMMIT.
    const ids = [...new Set(lineas.map((l) => l.productoId))].sort();
    const { rows: stock } = await tx.consultar('SELECT producto_id, cantidad, reservado FROM stock WHERE producto_id = ANY($1) ORDER BY producto_id FOR UPDATE', [ids]);
    const porProducto = new Map(stock.map((s) => [s.producto_id, s]));

    // 3. Comprobar TODAS las líneas antes de reservar ninguna: la reserva es todo o nada
    const faltantes = lineas.filter((l) => { const s = porProducto.get(l.productoId); return !s || s.cantidad - s.reservado < l.cantidad; }).map((l) => l.productoId);
    if (faltantes.length > 0) {
      await outbox.encolar(tx, [{ tipo: 'stock.rechazado', carga: { pedidoId, motivo: 'SIN_STOCK', productosSinStock: faltantes } }]);
      logger.info({ pedidoId, faltantes }, 'reserva rechazada');
      return { estado: 'RECHAZADA', faltantes };                     // sin reserva: no hay nada que liberar después
    }

    // 4. Reservar: incrementar reservado (el CHECK reservado <= cantidad de 02-04 es la última red) y crear la reserva
    const reservaId = `res-${randomUUID().slice(0, 8)}`;
    const expiraEn = new Date(Date.now() + ttlSegundos * 1000);
    for (const l of lineas) await tx.consultar('UPDATE stock SET reservado = reservado + $1 WHERE producto_id = $2', [l.cantidad, l.productoId]);
    await tx.consultar(`INSERT INTO reservas (reserva_id, pedido_id, estado, expira_en) VALUES ($1,$2,'ACTIVA',$3)`, [reservaId, pedidoId, expiraEn]);
    for (const l of lineas) await tx.consultar('INSERT INTO lineas_reserva (reserva_id, producto_id, cantidad) VALUES ($1,$2,$3)', [reservaId, l.productoId, l.cantidad]);

    // 5. El evento sale por el outbox en la MISMA transacción (02-05 §7): o reserva + evento, o nada
    await outbox.encolar(tx, [{ tipo: 'stock.reservado', carga: { pedidoId, reservaId, lineas, expiraEn: expiraEn.toISOString() } }]);
    logger.info({ pedidoId, reservaId, expiraEn }, 'stock reservado');
    return { estado: 'RESERVADA', reservaId };
  };
}
module.exports = { crearCasoUsoReservarStock };

Los otros dos casos de uso son cortos y simétricos: consumirReserva(pedidoId, tx) pasa la reserva ACTIVA a CONSUMIDA y ejecuta UPDATE stock SET cantidad = cantidad - c, reservado = reservado - c por línea (el descuento definitivo que en el monolito estaba dentro de la transacción de crearPedido); liberarReserva(pedidoId, tx) es literalmente el esqueleto de 02-05 §5 (LIBERADA, reservado - c, evento stock.liberado). Ambos empiezan con "si no hay reserva o no está ACTIVA, no hacer nada": la mitad de la idempotencia. El consumidor solo enruta:

// src/mensajeria/consumidorPedidos.js
const { crearConsumidor } = require('@techcorp/comun-http/mensajeria/consumidor');

function crearConsumidorPedidos({ canal, idempotencia, reservarStock, consumirReserva, liberarReserva, logger }) {
  return crearConsumidor({
    canal, cola: 'inventario.pedidos', idempotencia, logger, prefetch: 10,
    routingKeys: ['pedido.creado', 'pedido.confirmado', 'pedido.cancelado'],
    manejadores: {
      // La carga de pedido.creado (02-05 §4) trae lineas[{productoId, cantidad}]: Inventario no consulta a nadie
      'pedido.creado':     (sobre, tx) => reservarStock({ pedidoId: sobre.carga.pedidoId, lineas: sobre.carga.lineas }, tx),
      'pedido.confirmado': (sobre, tx) => consumirReserva(sobre.carga.pedidoId, tx),
      'pedido.cancelado':  (sobre, tx) => liberarReserva(sobre.carga.pedidoId, tx)     // C2 de la saga; también para TIMEOUT_PAGO
    }
  });
}
module.exports = { crearConsumidorPedidos };

Y la caducidad, la red de seguridad independiente del vigilante de Pedidos (02-05 §5, 06-03 §9): cada minuto, UPDATE reservas SET estado='LIBERADA' WHERE estado='ACTIVA' AND expira_en < now() RETURNING … dentro de una transacción que también resta reservado y encola stock.liberado con motivo: 'CADUCADA', con FOR UPDATE SKIP LOCKED para que dos réplicas no se pisen. Si alguna vez caduca una reserva de un pedido que no está CANCELADO, algo falla en la saga: se loguea con error y lo detecta la reconciliación. POST /v1/reservas reutiliza reservarStock dentro de bd.transaccion y responde 201 + Location o 409 SIN_STOCK con productosSinStock en el problema (03-01 §5.5).

  1. servicio-pagos

Puerto 3003, equipo de Pagos y comunicaciones, PostgreSQL pagos. Es el único servicio que habla con la pasarela externa y el único con salida a Internet (07-04).

Responsabilidad Cobrar cuando hay stock reservado, reembolsar cuando un pedido cobrado se cancela, recibir confirmaciones de la pasarela, guardar métodos de pago tokenizados
Endpoints POST /v1/webhooks/pasarela (HMAC, 07-02 §9, solo referenciado), POST /v1/metodos-pago (cliente: guarda el tok_… que el navegador obtuvo de la pasarela, 07-03), GET /v1/pagos?pedidoId= (operador), POST /v1/pagos/{id}/reembolsos (operador; auditado)
Consume (cola pagos.stock) pedido.creado → registrar el pago PENDIENTE (importe, clienteId); stock.reservado → cobrar; pedido.cancelado → reembolsar si estaba CAPTURADO, anular si PENDIENTE
Publica pago.confirmado, pago.rechazado, pago.reembolsado
Tablas pagos (pago_id, pedido_id UNIQUE, cliente_id, importe, moneda, estado, referencia_pasarela, intentos, creado_en, actualizado_en), metodos_pago (cliente_id, referencia_pasarela, predeterminado), webhooks_recibidos, auditoria, outbox, eventos_procesados
config.js PUERTO=3003, PAGOS_DB_URL, RABBITMQ_URL, PASARELA_URL, PASARELA_API_KEY y PASARELA_WEBHOOK_SECRET (Secret pagos-pasarela), PASARELA_TIMEOUT_MS=5000, PAGO_NUEVO_PROVEEDOR=false (flag), OUTBOX_INTERVALO_MS, LOG_NIVEL, OTEL_*

Un matiz de diseño que 02-05 dejaba implícito ("Pagos cobra sin consultar a nadie") y aquí se hace explícito: Pagos también escucha pedido.creado, porque stock.reservado es un evento de Inventario que no debe llevar importes ni clientes. Al recibir pedido.creado registra el pago en PENDIENTE con importe y clienteId; al recibir stock.reservado cobra. Si por concurrencia stock.reservado llega antes de haber procesado pedido.creado, el manejador lanza un error transitorio y el mensaje vuelve en 30 s por la cola de reintento (06-03): la ordenación se resuelve con el mecanismo que ya existe, no con uno nuevo. Los estados del pago son los de 02-03 (AUTORIZADO, CAPTURADO, RECHAZADO, REEMBOLSADO) más tres de implementación: PENDIENTE (registrado, sin hablar aún con la pasarela), EN_CURSO (llamada en marcha) y ANULADO (cancelado antes de cobrar).

El adaptador a la pasarela es un ACL con las cuatro protecciones de 06-03 (ej. 1) y el branch by abstraction de la flag:

// src/clientes/pasarelaCliente.js — ACL hacia la pasarela: el resto de Pagos solo ve { ok, referencia, motivo }
const pLimit = require('p-limit');
const { crearClienteHttp, reintentar, crearCircuitBreaker } = require('@techcorp/comun-http');

function crearPasarelaCliente({ urlBase, apiKey, timeoutMs, nuevoProveedor, logger, metricas }) {
  const http = crearClienteHttp({ urlBase, timeoutMs, nombre: 'pasarela', cabeceras: { Authorization: `Bearer ${apiKey}` } });
  const breaker = crearCircuitBreaker({ nombre: 'pasarela', alCambiar: ({ estado }) => metricas.breaker.set({ dependencia: 'pasarela' }, estado) });
  const limite = pLimit(10);                                                          // bulkhead: 10 cobros simultáneos por réplica
  // Dos proveedores, misma interfaz: la flag PAGO_NUEVO_PROVEEDOR elige el traductor (02-02 §4.2, 04-03 §8)
  const traducir = nuevoProveedor ? require('./traductores/proveedorB') : require('./traductores/proveedorA');

  async function llamar(ruta, cuerpo, { pedidoId, requestId }) {
    // Idempotency-Key = pedidoId: si el primer intento cobró y la respuesta se perdió, el segundo devuelve el MISMO cobro
    return limite(() => breaker.ejecutar(() => reintentar(
      () => http.peticion(ruta, { metodo: 'POST', cuerpo, requestId, cabeceras: { 'Idempotency-Key': pedidoId } }),
      { intentos: 3, baseMs: 500, reintentarSi: (err) => err.transitorio === true }      // 503/timeout sí; 402 (rechazado) no
    )));
  }
  return {
    // cobrar → { ok: true, referencia } | { ok: false, motivo: 'TARJETA_RECHAZADA' | 'FONDOS_INSUFICIENTES' | ... }
    cobrar: async ({ pedidoId, importe, moneda, referenciaCliente, requestId }) =>
      traducir.cobro(await llamar(traducir.rutaCobro, traducir.cuerpoCobro({ pedidoId, importe, moneda, referenciaCliente }), { pedidoId, requestId })),
    reembolsar: async ({ pedidoId, referencia, importe, requestId }) =>
      traducir.reembolso(await llamar(traducir.rutaReembolso(referencia), { importe }, { pedidoId: `${pedidoId}-reembolso`, requestId })),
    consultar: ({ pedidoId, requestId }) => llamar(traducir.rutaConsulta, { clave: pedidoId }, { pedidoId, requestId }).then(traducir.cobro)
  };
}
module.exports = { crearPasarelaCliente };

Y el caso de uso de cobro. Fíjate en que la llamada a la pasarela va fuera de la transacción (el error 5 de crearPedido en 01-05: nunca una llamada externa dentro de una transacción) y en cómo se sobrevive a una caída entre el cobro y el COMMIT:

// src/casos-uso/cobrarPedido.js
function crearCasoUsoCobrarPedido({ bd, outbox, pasarela, logger }) {
  // Se invoca desde el manejador de stock.reservado. NO recibe tx: gestiona sus propias transacciones cortas,
  // porque entre medias hay una llamada externa de hasta 5 s que no debe mantener filas bloqueadas.
  return async function cobrarPedido({ pedidoId, requestId }) {
    // 1. Transacción corta: leer el pago y marcarlo EN_CURSO (si ya está CAPTURADO/RECHAZADO: duplicado, salir)
    const pago = await bd.transaccion(async (tx) => {
      const p = (await tx.consultar('SELECT * FROM pagos WHERE pedido_id = $1 FOR UPDATE', [pedidoId])).rows[0];
      if (!p) throw Object.assign(new Error('pago aún no registrado (pedido.creado no procesado)'), { transitorio: true });   // → reintento 30 s
      if (p.estado !== 'PENDIENTE' && p.estado !== 'EN_CURSO') return null;                                                    // ya resuelto: idempotente
      await tx.consultar(`UPDATE pagos SET estado = 'EN_CURSO', intentos = intentos + 1, actualizado_en = now() WHERE pago_id = $1`, [p.pago_id]);
      return { ...p, reintentoTrasCaida: p.estado === 'EN_CURSO' };
    });
    if (!pago) return;

    // 2. Fuera de la transacción: hablar con la pasarela. Si el proceso murió con el pago EN_CURSO, primero se CONSULTA
    //    por la clave de idempotencia (¿llegó a cobrarse?) antes de volver a cobrar (06-03 ej. 1).
    const metodo = (await bd.consultar('SELECT referencia_pasarela FROM metodos_pago WHERE cliente_id = $1 AND predeterminado', [pago.cliente_id])).rows[0];
    let resultado;
    if (!metodo) resultado = { ok: false, motivo: 'SIN_METODO_PAGO' };
    else if (pago.reintentoTrasCaida) resultado = await pasarela.consultar({ pedidoId, requestId }).catch(() => null);
    if (!resultado) resultado = await pasarela.cobrar({ pedidoId, importe: pago.importe, moneda: pago.moneda, referenciaCliente: metodo.referencia_pasarela, requestId });
    // (si la pasarela lanza un error transitorio tras agotar reintentos, se propaga: el consumidor lo manda a .reintento y el pago sigue EN_CURSO)

    // 3. Transacción corta: resultado + evento, juntos. UNIQUE(pedido_id) y el estado garantizan "un pedido, un cobro".
    await bd.transaccion(async (tx) => {
      if (resultado.ok) {
        await tx.consultar(`UPDATE pagos SET estado = 'CAPTURADO', referencia_pasarela = $2, actualizado_en = now() WHERE pago_id = $1`, [pago.pago_id, resultado.referencia]);
        await outbox.encolar(tx, [{ tipo: 'pago.confirmado', carga: { pedidoId, pagoId: pago.pago_id, importe: Number(pago.importe), moneda: pago.moneda } }]);
      } else {
        await tx.consultar(`UPDATE pagos SET estado = 'RECHAZADO', actualizado_en = now() WHERE pago_id = $1`, [pago.pago_id]);
        await outbox.encolar(tx, [{ tipo: 'pago.rechazado', carga: { pedidoId, pagoId: pago.pago_id, motivo: resultado.motivo } }]);
      }
    });
    logger.info({ pedidoId, pagoId: pago.pago_id, ok: resultado.ok, motivo: resultado.motivo }, 'cobro resuelto');
  };
}
module.exports = { crearCasoUsoCobrarPedido };

El consumidor de pagos.stock tiene la misma forma que el de Inventario, con tres manejadores: 'pedido.creado'INSERT INTO pagos (pago_id, pedido_id, cliente_id, importe, moneda, estado) VALUES ('pag-…', $1, $2, $3, 'EUR', 'PENDIENTE') ON CONFLICT (pedido_id) DO NOTHING; 'stock.reservado'cobrarPedido; 'pedido.cancelado'reembolsarSiProcede: si el pago está CAPTURADO, pasarela.reembolsar y pago.reembolsado (C3 de 02-05, la rama rara: TIMEOUT_PAGO justo después de cobrar, o cancelación del cliente en plazo); si está PENDIENTE, ANULADO sin llamar a nadie (SIN_STOCK es el caso habitual). El webhook de 07-02 §9 hace lo mismo que el paso 3 de cobrarPedido cuando la pasarela confirma de forma asíncrona (cobros diferidos, contracargos), y cada reembolso manual pasa por auditoria.registrar (07-03 §9).

  1. servicio-notificaciones

Puerto 3005, equipo de Pagos y comunicaciones. Nació de servicios/correo.js del monolito y es deliberadamente el servicio más simple: sin API de negocio, un consumidor y un adaptador de correo.

Responsabilidad Enviar el correo de confirmación o de cancelación de cada pedido, una sola vez
Endpoints Solo /health/* y /metrics (no expuesto en el gateway)
Consume (cola notificaciones.pedidos) pedido.creado → guardar destinatario; pedido.confirmado → correo CONFIRMACION; pedido.cancelado → correo CANCELACION con el motivo
Publica Nada (hoy). notificacion.enviada queda como extensión
Tablas destinatarios (pedido_id PK, email, nombre, creado_en) con retención de 7 días (07-03 §8), envios (envio_id, pedido_id, tipo, destinatario, estado, enviado_en, UNIQUE (pedido_id, tipo)), eventos_procesados
config.js PUERTO=3005, NOTIFICACIONES_DB_URL, RABBITMQ_URL, CORREO_PROVEEDOR=consola|http, CORREO_URL, CORREO_API_KEY (Secret notificaciones-correo), [email protected], CORREO_TIMEOUT_MS=3000, LOG_NIVEL, OTEL_*

Sobre destinatarios: 07-03 decidió que los datos personales viajan solo en pedido.creado. Por eso Notificaciones se suscribe también a ese evento (el binding extra que proponía el ejercicio 1 de 03-02) y guarda email/nombre siete días; pedido.confirmado y pedido.cancelado llegan sin ellos. Si un pedido.confirmado llega antes que su pedido.creado (posible con dos réplicas), el manejador lanza transitorio y espera 30 s: el mismo patrón que Pagos.

// src/clientes/correoCliente.js — adaptador con dos modos: 'consola' (desarrollo, E2E) y 'http' (proveedor ficticio)
const { crearClienteHttp, reintentar } = require('@techcorp/comun-http');

function crearCorreoCliente({ proveedor, urlBase, apiKey, remitente, timeoutMs, logger }) {
  if (proveedor === 'consola') {
    return { enviar: async (m) => { logger.info({ para: m.para, asunto: m.asunto }, '[correo consola]'); return { proveedorId: `consola-${Date.now()}` }; } };
  }
  const http = crearClienteHttp({ urlBase, timeoutMs, nombre: 'correo', cabeceras: { Authorization: `Bearer ${apiKey}` } });
  return {
    // enviar({ para, asunto, html, texto, referencia }) → { proveedorId }. `referencia` (pedidoId:tipo) es la clave de idempotencia
    // del proveedor: si reintentamos tras un timeout, no manda dos correos. Un 4xx (dirección inválida) NO es transitorio → DLQ.
    enviar: (m) => reintentar(() => http.peticion('/v1/mensajes', { metodo: 'POST', cuerpo: { de: remitente, ...m }, cabeceras: { 'Idempotency-Key': m.referencia } }),
                              { intentos: 2, baseMs: 300 })
  };
}
module.exports = { crearCorreoCliente };
// src/casos-uso/notificarPedido.js — el manejador de pedido.confirmado / pedido.cancelado
const plantillas = require('../dominio/plantillas');   // confirmacion(p) y cancelacion(p, motivo) → { asunto, html, texto }; MOTIVOS → texto legible
const TIPO = { 'pedido.confirmado': 'CONFIRMACION', 'pedido.cancelado': 'CANCELACION' };

function crearCasoUsoNotificarPedido({ correo, logger }) {
  return async function notificarPedido(sobre, tx) {
    const { pedidoId } = sobre.carga, tipo = TIPO[sobre.tipo];
    // 1. Destinatario guardado al recibir pedido.creado. Si aún no está: transitorio (llegó antes de tiempo)
    const dest = (await tx.consultar('SELECT email, nombre FROM destinatarios WHERE pedido_id = $1', [pedidoId])).rows[0];
    if (!dest) throw Object.assign(new Error(`sin destinatario para ${pedidoId}`), { transitorio: true });
    // 2. Idempotencia de negocio además de la de eventoId: un pedido, un correo de cada tipo (UNIQUE (pedido_id, tipo)).
    //    Si un pedido.confirmado se reemitiera con OTRO eventoId (reprocesado desde la DLQ, 06-03), tampoco duplicaría.
    const { rowCount } = await tx.consultar(`INSERT INTO envios (envio_id, pedido_id, tipo, destinatario, estado) VALUES (gen_random_uuid(), $1, $2, $3, 'ENVIANDO') ON CONFLICT DO NOTHING`, [pedidoId, tipo, dest.email]);
    if (rowCount === 0) { logger.info({ pedidoId, tipo }, 'correo ya enviado, ignorado'); return; }
    // 3. Enviar. Si falla de forma transitoria, la transacción hace ROLLBACK (la fila ENVIANDO desaparece) y el mensaje
    //    vuelve en 30 s; si el proveedor cobró el envío pero perdimos la respuesta, su Idempotency-Key evita el duplicado.
    const mensaje = sobre.tipo === 'pedido.confirmado' ? plantillas.confirmacion({ ...sobre.carga, nombre: dest.nombre }) : plantillas.cancelacion({ ...sobre.carga, nombre: dest.nombre }, sobre.carga.motivo);
    const { proveedorId } = await correo.enviar({ para: dest.email, referencia: `${pedidoId}:${tipo}`, ...mensaje });
    await tx.consultar(`UPDATE envios SET estado = 'ENVIADO', proveedor_id = $3, enviado_en = now() WHERE pedido_id = $1 AND tipo = $2`, [pedidoId, tipo, proveedorId]);
    logger.info({ pedidoId, tipo, proveedorId }, 'correo enviado');
  };
}
module.exports = { crearCasoUsoNotificarPedido };

Aquí la llamada externa va dentro de la transacción de procesarUnaVez, a diferencia de Pagos. Es una decisión consciente y conviene entender por qué es aceptable: la transacción no bloquea filas que otros necesiten (solo envios del propio pedido), el timeout es de 3 s, y el beneficio —que un fallo de envío deshaga la marca ENVIANDO y el registro en eventos_procesados a la vez— simplifica mucho el código. En Pagos, con 5 s de pasarela y dinero de por medio, la misma decisión sería mala. Las plantillas (dominio/plantillas.js) traducen los motivos de pedido.cancelado a lenguaje de cliente: SIN_STOCK → "el producto se ha agotado", PAGO_RECHAZADO → "no hemos podido cobrar", TIMEOUT_PAGO → "no se ha completado el pago a tiempo", CLIENTE_ARREPENTIDO → "hemos cancelado tu pedido como pediste".

  1. servicio-clientes

Puerto 3004, equipo de Experiencia de compra, PostgreSQL clientes. Fue el último en extraerse (08-01, fase 6) y sustituye al stub scripts/stubClientes.js que arrastrábamos desde 04-04.

Responsabilidad Perfil y direcciones del cliente; alta vinculada a la identidad de Keycloak; derecho de supresión
Endpoints POST /v1/clientes (alta con el token del usuario recién registrado en Keycloak), GET /v1/clientes/{id} (propio cliente, operador/admin o servicio con clientes:leer, 07-01 ej. 2), PUT /v1/clientes/{id} (propio u operador; publica cliente.actualizado), DELETE /v1/clientes/{id} (propio o admin; RGPD), `GET
Consume Nada
Publica cliente.actualizado (→ pedidos.clientes, réplica clientes_ref), cliente.eliminado
Tablas clientes (cliente_id, keycloak_sub UNIQUE, email UNIQUE, nombre, activo, creado_en, actualizado_en), direcciones (direccion_id, cliente_id FK, calle, cp, ciudad, pais, predeterminada), outbox, auditoria
config.js PUERTO=3004, CLIENTES_DB_URL, RABBITMQ_URL, KEYCLOAK_URL=https://auth.techcorp.example, KEYCLOAK_REALM=techcorp, KEYCLOAK_ADMIN_CLIENT_ID=servicio-clientes y KEYCLOAK_ADMIN_CLIENT_SECRET (Secret clientes-keycloak; cliente client credentials con el rol manage-users del realm), KEYCLOAK_ISSUER/AUDIENCE, OUTBOX_INTERVALO_MS, LOG_NIVEL, OTEL_*

El caso de uso que cierra el círculo con 07-01 es el alta: Ana se registra en Keycloak (formulario del realm), la tienda hace login y llama a POST /v1/clientes con su token; ese token todavía no tiene el claim clienteId. Clientes crea c-…, escribe el atributo en Keycloak y, en el siguiente refresh, el token ya viaja con clienteId: "c-1024".

// src/casos-uso/altaCliente.js
const { randomUUID } = require('node:crypto');
const { ErrorNegocio } = require('@techcorp/comun-http');

function crearCasoUsoAltaCliente({ bd, outbox, keycloak, logger }) {
  // usuario = req.usuario de autenticar() (07-01): { sub, email, nombre }. El cuerpo puede traer una dirección inicial.
  return async function altaCliente({ nombre, direccion }, usuario) {
    if (!usuario.sub) throw new ErrorNegocio('NO_AUTENTICADO', 'alta sin identidad', 401);
    // 1. Idempotente por keycloak_sub: repetir el POST devuelve el mismo cliente (el navegador reintenta a menudo aquí)
    const existente = (await bd.consultar('SELECT cliente_id FROM clientes WHERE keycloak_sub = $1', [usuario.sub])).rows[0];
    if (existente) return { clienteId: existente.cliente_id, repetido: true };

    const clienteId = `c-${randomUUID().slice(0, 8)}`;
    // 2. Cliente + dirección + evento en UNA transacción; email desde el token, no desde el cuerpo (07-03: no confiar en la entrada)
    await bd.transaccion(async (tx) => {
      await tx.consultar(`INSERT INTO clientes (cliente_id, keycloak_sub, email, nombre, activo) VALUES ($1,$2,$3,$4,true)`, [clienteId, usuario.sub, usuario.email, nombre]);
      if (direccion) await tx.consultar(`INSERT INTO direcciones (direccion_id, cliente_id, calle, cp, ciudad, pais, predeterminada) VALUES ($1,$2,$3,$4,$5,$6,true)`,
                                        [`dir-${randomUUID().slice(0, 6)}`, clienteId, direccion.calle, direccion.cp, direccion.ciudad, direccion.pais ?? 'ES']);
      await outbox.encolar(tx, [{ tipo: 'cliente.actualizado', carga: { clienteId, nombre, email: usuario.email, actualizadoEn: new Date().toISOString() } }]);
    });
    // 3. Fuera de la transacción: escribir el atributo en Keycloak (llamada externa; reintentable e idempotente: PUT del atributo).
    //    Si falla, el cliente existe igualmente y un job 'sincronizarKeycloak' lo reintenta: el token sin clienteId solo
    //    impide crear pedidos unos minutos (403 en Pedidos, 07-01), no compra nada mal.
    try { await keycloak.establecerAtributo(usuario.sub, 'clienteId', clienteId); }
    catch (err) { logger.error({ err, clienteId, sub: usuario.sub }, 'no se pudo escribir clienteId en Keycloak; pendiente de sincronizar'); await bd.consultar('INSERT INTO keycloak_pendientes (cliente_id) VALUES ($1) ON CONFLICT DO NOTHING', [clienteId]); }
    logger.info({ clienteId }, 'cliente dado de alta');
    return { clienteId, repetido: false };
  };
}
module.exports = { crearCasoUsoAltaCliente };

Los otros dos casos de uso, resumidos: PUT /v1/clientes/{id} valida con zod (07-03 §3: nombre ≤ 120 caracteres, sin HTML), aplica la regla del propietario u operador, hace UPDATE clientes SET nombre, actualizado_en = now() y encola cliente.actualizado en la misma transacción; el consumidor pedidos.clientes de 04-04 lo aplica sobre clientes_ref con la protección por actualizadoEn del ejercicio 2 de 04-04. DELETE /v1/clientes/{id} (RGPD, 07-03 §8) anonimiza en vez de borrar (email = 'anon-<hash>@eliminado', nombre = 'Cliente eliminado', activo = false, direcciones borradas), encola cliente.eliminado, deshabilita el usuario en Keycloak, y registra CLIENTE_ELIMINADO en auditoria con el sub de quien lo pidió; Pedidos consume cliente.eliminado en pedidos.clientes para borrar la fila de clientes_ref y anonimizar direcciones de pedidos antiguos. keycloakCliente (src/clientes/keycloakCliente.js) es crearClienteHttp + crearProveedorToken (07-01 §8) sobre la API de administración (PUT /admin/realms/techcorp/users/{sub} con attributes.clienteId).

  1. Mapa final de eventos y la saga con los seis servicios

Con los cuatro servicios escritos, este es el mapa definitivo. Respecto a la tabla de 03-02 hay dos bindings más (pedido.creado en pagos.stock y en notificaciones.pedidos), un consumidor más de cliente.* (analítica, 08-01 §8) y todos los eventos de compensación con productor real:

Evento Productor Consumidores (cola) Carga esencial
pedido.creado Pedidos Inventario (inventario.pedidos), Pagos (pagos.stock), Notificaciones (notificaciones.pedidos), analítica pedidoId, clienteId, cliente{email,nombre}, direccionEnvio, lineas[], total
stock.reservado Inventario Pedidos (pedidos.saga), Pagos (pagos.stock) pedidoId, reservaId, lineas, expiraEn
stock.rechazado Inventario Pedidos (pedidos.saga) pedidoId, motivo: SIN_STOCK, productosSinStock[]
pago.confirmado Pagos Pedidos (pedidos.saga) pedidoId, pagoId, importe, moneda
pago.rechazado Pagos Pedidos (pedidos.saga) pedidoId, pagoId, motivo
pedido.confirmado Pedidos Inventario, Notificaciones, analítica pedidoId, clienteId, lineas, total (sin datos personales)
pedido.cancelado Pedidos (consumidor de saga, vigilante, DELETE /v1/pedidos/{id}) Inventario, Pagos, Notificaciones, analítica pedidoId, motivo
stock.liberado Inventario (nadie hoy; analítica) pedidoId, reservaId, motivo?
pago.reembolsado Pagos (nadie hoy; analítica) pedidoId, pagoId, importe
cliente.actualizado / cliente.eliminado Clientes Pedidos (pedidos.clientes), analítica clienteId, nombre, email, actualizadoEn / clienteId
producto.actualizado Catálogo analítica productoId, nombre, precio, categoria, publicado
sequenceDiagram
    autonumber
    actor Ana
    participant GW as gateway :8080
    participant PED as servicio-pedidos :3002
    participant CLI as servicio-clientes :3004
    participant CAT as servicio-catalogo :3001
    participant MQ as RabbitMQ techcorp.eventos
    participant INV as servicio-inventario :3006
    participant PAG as servicio-pagos :3003
    participant PSP as Pasarela
    participant NOT as servicio-notificaciones :3005
    Ana->>GW: POST /api/v1/pedidos (JWT clienteId=c-1024, Idempotency-Key)
    GW->>PED: POST /v1/pedidos + X-Usuario-*
    par validaciones síncronas
        PED->>CLI: GET /v1/clientes/c-1024 (token de servicio)
        PED->>CAT: GET /v1/productos?ids=p-501,p-777
    end
    PED->>PED: tx: pedido PENDIENTE + outbox(pedido.creado)
    PED-->>Ana: 202 Location /v1/pedidos/ped-88213
    PED--)MQ: pedido.creado (relay)
    MQ--)INV: inventario.pedidos
    MQ--)PAG: pagos.stock → pago PENDIENTE
    MQ--)NOT: notificaciones.pedidos → destinatario
    INV->>INV: tx: FOR UPDATE, reserva ACTIVA + outbox(stock.reservado)
    INV--)MQ: stock.reservado
    MQ--)PED: pedidos.saga → STOCK_RESERVADO
    MQ--)PAG: pagos.stock → cobrar
    PAG->>PSP: POST cobro (Idempotency-Key = pedidoId)
    PSP-->>PAG: ok, referencia
    PAG->>PAG: tx: CAPTURADO + outbox(pago.confirmado)
    PAG--)MQ: pago.confirmado
    MQ--)PED: pedidos.saga → PAGADO → CONFIRMADO + outbox(pedido.confirmado)
    PED--)MQ: pedido.confirmado
    MQ--)INV: reserva CONSUMIDA, cantidad -= c
    MQ--)NOT: correo CONFIRMACION
    NOT->>Ana: "Pedido ped-88213 confirmado"

  1. El recorrido de ped-88213 por el sistema completo

El pedido de Ana (c-1024, p-501 ×1 y p-777 ×2, 79,70 €), con tiempos ficticios coherentes con la traza de 06-02 §8 (gateway 3 ms, Pedidos 180 ms, Catálogo 40 ms, Clientes 25 ms):

Instante (UTC) Servicio Qué ocurre Fila / mensaje que aparece
10:42:00.000 gateway POST /api/v1/pedidos; JWT válido, clienteId=c-1024 = cuerpo log requestId=7f3c…, traceparent
10:42:00.003 pedidos Promise.all: Clientes (25 ms) y Catálogo (40 ms)
10:42:00.180 pedidos (PG pedidos) COMMIT pedidos(ped-88213, PENDIENTE, 79.70), 2 lineas_pedido, clientes_ref(c-1024), outbox(evt-a1, pedido.creado), claves_idempotencia
10:42:00.183 gateway → Ana 202 Accepted, Location: /v1/pedidos/ped-88213
10:42:00.420 pedidos (relay) publica y marca outbox.publicado_en; en techcorp.eventos → 4 colas
10:42:00.470 pagos (PG pagos) pedido.creado pagos(pag-9001, ped-88213, c-1024, 79.70, PENDIENTE), eventos_procesados(evt-a1, pagos.stock)
10:42:00.475 notificaciones pedido.creado destinatarios(ped-88213, [email protected], Ana Ruiz)
10:42:00.490 inventario (PG inventario) FOR UPDATE p-501, p-777; reserva stock.reservado p-501 +1, p-777 +2; reservas(res-4471, ACTIVA, expira 10:57:00), 2 lineas_reserva, outbox(evt-b2, stock.reservado), eventos_procesados(evt-a1, inventario.pedidos)
10:42:00.720 inventario (relay) publica stock.reservadopedidos.saga, pagos.stock
10:42:00.760 pedidos pedidos.saga pedidos.estado = STOCK_RESERVADO, eventos_procesados(evt-b2, pedidos.saga); GET de Ana devolvería este estado
10:42:00.790 pagos pagos.stock: EN_CURSO; llamada a la pasarela (1,2 s) pagos.estado = EN_CURSO, intentos = 1
10:42:02.010 pagos respuesta OK ch_7f3a… pagos.estado = CAPTURADO, referencia_pasarela, outbox(evt-c3, pago.confirmado), eventos_procesados(evt-b2, pagos.stock)
10:42:02.260 pagos (relay) → pedidos pago.confirmado en pedidos.saga pedidos.estado = CONFIRMADO (PAGADO → CONFIRMADO en la misma transacción, 04-04 §8), outbox(evt-d4, pedido.confirmado), eventos_procesados(evt-c3, pedidos.saga)
10:42:02.500 pedidos (relay) publica pedido.confirmadoinventario.pedidos, notificaciones.pedidos, analítica
10:42:02.540 inventario consumir reservas(res-4471) = CONSUMIDA; stock p-501 cantidad −1, reservado −1; p-777 −2/−2
10:42:02.900 notificaciones correo envios(ped-88213, CONFIRMACION, ENVIADO, proveedor_id); Ana recibe el correo
10:42:02.9 métricas saga_duracion_segundos observa 2,7 s; pedidos_creados_total +1 panel "Saga de pedidos" (06-01)

Compáralo con el diagrama de 01-05 §5: son las mismas ocho operaciones de negocio, pero en cinco transacciones locales en cuatro bases de datos, unidas por seis mensajes, cada una con su marca de idempotencia. Y si en 10:42:00.490 no hubiera stock: stock.rechazadoCANCELADO (SIN_STOCK) a las 10:42:00.8, pedido.cancelado → Pagos anula el PENDIENTE sin llamar a nadie, Notificaciones envía "producto agotado", Inventario no tiene reserva que liberar. Y si la pasarela rechazara: pago.rechazadoCANCELADO (PAGO_RECHAZADO) → Inventario libera (stock.liberado), Notificaciones "no hemos podido cobrar".

  1. Pruebas: qué pactos y qué E2E añade cada servicio

Cada servicio llega con la pirámide de 04-05 (unitarias del dominio, componente con crearApp y dobles, integración con Testcontainers). Lo que suma al conjunto son los contratos y las E2E:

Servicio Pactos (consumidor → proveedor) E2E que añade en plataforma/pruebas/e2e/
Inventario HTTP: reconciliar-reservas → Pedidos GET /v1/pedidos/{id} (estado 'existe ped-… CANCELADO'). Mensajes: Inventario como consumidor de pedido.creado (Pedidos verifica que su carga cumple lineas[{productoId, cantidad}]); Inventario como proveedor de stock.reservado para Pedidos y Pagos (la acción correctiva de INC-2031: lineas nunca null) sinStock.e2e.test.js: pedido con p-802 (stock 0) → CANCELADO con motivo SIN_STOCK en < 5 s y GET /v1/stock/p-802 sin reservado
Pagos Mensajes: consumidor de pedido.creado y stock.reservado; proveedor de pago.confirmado/pago.rechazado para Pedidos. Hacia la pasarela: no hay Pact (tercero) → pruebas de componente contra un servidor falso con los casos 200/402/503/timeout/duplicado (06-03 §11) pagoRechazado.e2e.test.js: método de pago ficticio tok_rechazarCANCELADO (PAGO_RECHAZADO), stock.liberado y correo CANCELACION en el modo consola
Notificaciones Mensajes: consumidor de pedido.creado, pedido.confirmado, pedido.cancelado (Pedidos verifica) Se cubre con las anteriores comprobando envios (una fila por pedido y tipo)
Clientes HTTP: Pedidos → Clientes GET /v1/clientes/{id} (el pacto del ejercicio 2 de 04-05, ahora verificado por el servicio real en vez del stub); Clientes como proveedor de cliente.actualizado/cliente.eliminado para Pedidos clienteEliminado.e2e.test.js: DELETE /v1/clientes/{id}clientes_ref desaparece en Pedidos y GET /v1/clientes/{id} → 404
(ya existente) Pedidos → Catálogo GET /v1/productos?ids= (04-05) crearPedido.e2e.test.js (04-05, 05-01): ahora llega a CONFIRMADO con los servicios reales, sin publicarEvento.js

Los pactos de mensajes usan la misma herramienta (@pact-foundation/pact, MessageConsumerPact / MessageProviderPact) y el mismo Broker con can-i-deploy de 05-03 §4: si Pedidos cambia la carga de pedido.creado, el CI de Pedidos sabe que rompe a tres consumidores antes de desplegar. Es la respuesta definitiva a la causa 1 de INC-2031.

Errores Comunes y Consejos

  • Copiar el consumidor de 04-04 en cada servicio en lugar de usar crearConsumidor. Cuatro copias del manejo de x-intentos, reintento y DLQ divergen en un mes. Es código técnico: librería (02-02 §6).
  • Bloquear filas de stock en orden distinto en dos rutas (ORDER BY producto_id en la reserva y sin orden en la entrada de almacén). Dos transacciones se esperan en cruz y PostgreSQL mata una con 40P01. Mismo orden siempre.
  • La llamada a la pasarela dentro de la transacción "para simplificar". Cinco segundos con la fila de pagos bloqueada y, peor, un ROLLBACK que deshace el registro de un cobro que la pasarela sí hizo. Transacciones cortas alrededor; consulta por clave de idempotencia si hay duda.
  • Confiar solo en eventoId para no enviar dos correos. Un reproceso desde la DLQ o un pedido.confirmado reemitido llevan otro eventoId. La idempotencia de negocio (UNIQUE (pedido_id, tipo)) es la que protege al cliente.
  • Poner email en pedido.confirmado "porque Notificaciones lo necesita". 07-03 lo restringió a pedido.creado; la solución es que Notificaciones lo guarde siete días, no reabrir la decisión.
  • Escribir el atributo en Keycloak dentro de la transacción del alta. Es una llamada externa; si Keycloak tarda, el INSERT espera; si falla, el cliente no existe y el usuario tampoco puede reintentar (ya está registrado en Keycloak). Fuera, reintentable, con cola de pendientes.
  • Consejo: cuando escribas el cuarto servicio con la misma plantilla, mide cuánto tardas. Si son horas y no días, la fase 0 de 08-01 y la regla de Luis han funcionado; si son días, algo de la plantilla o de la librería falta y hay que subirlo antes del quinto.

Ejercicios

Ejercicio 1: El cliente cancela a tiempo

Añade a servicio-pedidos la cancelación por el cliente (DELETE /v1/pedidos/{id} de 03-01, motivo CLIENTE_ARREPENTIDO), permitida solo en CONFIRMADO durante 30 minutos. Indica qué transición añadirías a la máquina de estados de 02-05, qué evento se publica y qué hace cada uno de los cuatro servicios de esta lección al recibirlo (una línea por servicio, con la tabla o llamada implicada).

Ejercicio 2: stock.reservado antes que pedido.creado

En Pagos, stock.reservado puede llegar antes de que pedido.creado esté procesado. Explica (a) por qué es posible aunque Pedidos publique pedido.creado antes de que exista stock.reservado; (b) qué ocurre paso a paso con el diseño de esta lección (transitorio, cola de reintento, x-intentos); (c) qué alternativa habría si en lugar de un error transitorio quisiéramos resolverlo sin esperar 30 s, y qué coste tiene.

Ejercicio 3: Un pacto de mensajes

Escribe, en pseudocódigo o con la API de Pact para mensajes, el contrato que Pagos declara como consumidor de stock.reservado: qué campos exige, con qué matchers, y qué provider state debería preparar Inventario para verificarlo. Explica qué habría detectado este pacto el 22 de julio de 2026 (INC-2031).

Soluciones

Ejercicio 1. Transición CONFIRMADO --cancelar.cliente--> CANCELADO (con la comprobación now() - actualizado_en < 30 min en el caso de uso, no en la tabla de transiciones), motivo CLIENTE_ARREPENTIDO, auditoría PEDIDO_CANCELADO (07-03) y evento pedido.cancelado { pedidoId, motivo } por el outbox. Reacciones: Inventario (inventario.pedidos): la reserva ya está CONSUMIDA, así que liberarReserva no hace nada; hace falta un caso nuevo, reponerPorCancelacion: si la reserva está CONSUMIDA, UPDATE stock SET cantidad = cantidad + c por línea y stock.repuesto (o marcarla DEVUELTA); Pagos (pagos.stock): reembolsarSiProcede encuentra el pago CAPTURADOpasarela.reembolsar con clave ped-…-reembolsoREEMBOLSADO + pago.reembolsado (la rama C3 de 02-05, ahora habitual); Notificaciones: correo CANCELACION con el texto de CLIENTE_ARREPENTIDO (ya existe en las plantillas), idempotente por (pedido_id, CANCELACION); Clientes: nada (no consume eventos de pedido). Y analítica registra la cancelación con motivo.

Ejercicio 2. (a) pedido.creado entra en pagos.stock antes que stock.reservado, pero la cola tiene varios mensajes en vuelo (prefetch 10) y dos réplicas: la réplica A toma pedido.creado y su transacción tarda 40 ms; la réplica B toma stock.reservado 30 ms después y lo ejecuta antes de que A haga COMMIT. El orden de una cola es de entrega, no de finalización. (b) cobrarPedido no encuentra la fila → lanza { transitorio: true }crearConsumidor publica el mensaje en pagos.stock.reintento con x-intentos: 1 y hace ack del original → 30 s después vuelve por techcorp.eventos.reintento → ahora la fila existe → cobro normal. Coste: 30 s más de saga para ese pedido (dentro del SLO de 60 s de 06-05, pero contando); con maxIntentos 5 habría 2 minutos de margen antes de la DLQ. (c) Alternativa: en el manejador de stock.reservado, si no hay pago, crear la fila PENDIENTE con los datos del propio evento —lo que exigiría que stock.reservado llevara importe y clienteId, acoplando a Inventario con datos que no son suyos— o consultar GET /v1/pedidos/{id} de forma síncrona (acoplamiento temporal, otro cliente HTTP, otro pacto). Ambas evitan la espera a costa de más acoplamiento; TechCorp acepta los 30 s ocasionales porque son raros (solo con concurrencia real) y el mecanismo ya existe.

Ejercicio 3.

// pagos/pruebas/contrato/stockReservado.consumidor.pact.test.js (esquema)
const mensajero = new MessageConsumerPact({ consumer: 'servicio-pagos', provider: 'servicio-inventario', dir: 'pactos' });
await mensajero
  .given('existe una reserva ACTIVA para ped-88213')                    // provider state que Inventario prepara con su repositorio en memoria
  .expectsToReceive('stock.reservado de un pedido con líneas')
  .withContent({ eventoId: like('evt-b2'), tipo: 'stock.reservado', version: integer(1), ocurridoEn: iso8601DateTime(),
                 carga: { pedidoId: regex(/^ped-[0-9a-f]{8}$/, 'ped-88213'), reservaId: like('res-4471'),
                          lineas: eachLike({ productoId: like('p-501'), cantidad: integer(1) }, { min: 1 }), expiraEn: iso8601DateTime() } })
  .verify(async (mensaje) => manejadorStockReservado(mensaje.contents, txFalsa));    // el manejador REAL de Pagos con la carga del pacto

Exige pedidoId con el formato de 07-03, reservaId, lineas como array de al menos un elemento con productoId y cantidad enteros, y expiraEn como fecha; con like/eachLike en vez de valores exactos (04-05: tipos y forma, no datos). Inventario, como proveedor, verifica en su CI que su publicador produce un mensaje que cumple ese contrato para ese estado. El 22 de julio, Inventario 2.3.0 publicó lineas: null: la verificación del proveedor habría fallado en el CI de Inventario antes de construir la imagen, can-i-deploy habría dicho que no, y pagos.stock no se habría atascado. (Aunque el manejador de Pagos que fallaba con TypeError era el de stock.reservado, que hoy no usa lineas; el pacto documenta igualmente lo que Pagos tolera y lo que no.)

Conclusión

El sistema de TechCorp está completo. A los tres componentes ya escritos —servicio-catalogo (04-02), servicio-pedidos (04-04) y el gateway (03-04/07-01)— hemos añadido, con la misma plantilla y la misma librería, los cuatro que faltaban: Inventario, dueño del invariante reservado <= cantidad, con la reserva todo-o-nada bajo FOR UPDATE en orden fijo, stock.reservado/stock.rechazado por outbox, consumo y liberación idempotentes y la caducidad de reservas como red independiente; Pagos, con el ACL pasarelaCliente (clave de idempotencia = pedidoId, reintentos solo de lo transitorio, circuit breaker, bulkhead, dos proveedores tras una flag), transacciones cortas alrededor de la llamada externa, UNIQUE (pedido_id) y la rama de reembolso; Notificaciones, con destinatarios de siete días para respetar la decisión RGPD de 07-03, envios con UNIQUE (pedido_id, tipo) como idempotencia de negocio y el adaptador de correo con modo consola; y Clientes, con el alta que escribe el claim clienteId en Keycloak fuera de la transacción, cliente.actualizado para la réplica de Pedidos y cliente.eliminado para el derecho de supresión. El mapa de eventos ha quedado cerrado (once tipos, cinco colas más la de analítica), la saga se ha dibujado con los seis servicios reales, ped-88213 ha atravesado cuatro bases de datos y seis mensajes en 2,9 segundos, y cada servicio ha aportado sus pactos (HTTP y de mensajes) y sus E2E.

Todo esto son repositorios con código y pruebas verdes. La siguiente lección los lleva a un clúster: el repositorio techcorp/plataforma completo, el compose.yaml con los seis servicios y toda la infraestructura, el orden de arranque en un clúster nuevo, la prueba de humo con un token de Keycloak, y la operación del día a día —un despliegue de Pedidos de punta a punta, la campaña de Black Friday, un incidente en la DLQ, la rotación de un secreto, la actualización de Node y una evolución de contrato— con su coste mensual aproximado.

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