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
- Estado de partida: lo que ya está escrito
- Lo común a los cuatro servicios nuevos
servicio-inventarioservicio-pagosservicio-notificacionesservicio-clientes- Mapa final de eventos y la saga con los seis servicios
- El recorrido de
ped-88213por el sistema completo - Pruebas: qué pactos y qué E2E añade cada servicio
- 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.
- 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.
servicio-inventario
servicio-inventarioPuerto 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).
servicio-pagos
servicio-pagosPuerto 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).
servicio-notificaciones
servicio-notificacionesPuerto 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 sí 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".
servicio-clientes
servicio-clientesPuerto 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).
- 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"
- El recorrido de
ped-88213 por el sistema completo
ped-88213 por el sistema completoEl 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.reservado → pedidos.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.confirmado → inventario.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.rechazado → CANCELADO (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.rechazado → CANCELADO (PAGO_RECHAZADO) → Inventario libera (stock.liberado), Notificaciones "no hemos podido cobrar".
- 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_rechazar → CANCELADO (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 dex-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_iden la reserva y sin orden en la entrada de almacén). Dos transacciones se esperan en cruz y PostgreSQL mata una con40P01. Mismo orden siempre. - La llamada a la pasarela dentro de la transacción "para simplificar". Cinco segundos con la fila de
pagosbloqueada y, peor, unROLLBACKque 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
eventoIdpara no enviar dos correos. Un reproceso desde la DLQ o unpedido.confirmadoreemitido llevan otroeventoId. La idempotencia de negocio (UNIQUE (pedido_id, tipo)) es la que protege al cliente. - Poner
emailenpedido.confirmado"porque Notificaciones lo necesita". 07-03 lo restringió apedido.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
INSERTespera; 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 CAPTURADO → pasarela.reembolsar con clave ped-…-reembolso → REEMBOLSADO + 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 pactoExige 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
- Conceptos Básicos de Microservicios
- Ventajas y Desventajas de los Microservicios
- Comparación con la Arquitectura Monolítica
- Cuándo Adoptar Microservicios: Criterios de Decisión
- El Caso Práctico del Curso: la Tienda Online de TechCorp
Módulo 2: Diseño de Microservicios
- Principios de Diseño de Microservicios
- Descomposición de Aplicaciones Monolíticas
- Definición de Bounded Contexts
- Gestión de Datos: una Base de Datos por Servicio
- Consistencia Distribuida: Sagas, CQRS y Event Sourcing
Módulo 3: Comunicación entre Microservicios
- APIs RESTful
- Mensajería Asíncrona
- Protocolos de Comunicación: gRPC, GraphQL
- API Gateway y Backend for Frontend
- Descubrimiento de Servicios y Balanceo de Carga
- Contratos y Versionado de APIs
Módulo 4: Implementación de Microservicios
- Elección de Tecnologías y Herramientas
- Desarrollo de un Microservicio Simple
- Gestión de Configuración
- Integración Práctica: Consumir APIs y Publicar Eventos
- Pruebas en Microservicios: Unitarias, de Integración y de Contrato
Módulo 5: Despliegue y Orquestación
- Contenedores y Docker
- Orquestación con Kubernetes
- CI/CD para Microservicios
- Estrategias de Despliegue: Rolling, Blue-Green y Canary
- Service Mesh: Istio y Linkerd
Módulo 6: Monitoreo y Mantenimiento
- Monitoreo y Logging
- Trazabilidad Distribuida con OpenTelemetry
- Gestión de Errores y Recuperación
- Escalabilidad y Rendimiento
- SLOs, Alertas y Gestión de Incidentes
Módulo 7: Seguridad en Microservicios
- Autenticación y Autorización
- Seguridad en la Comunicación
- Prácticas de Seguridad
- Seguridad en Contenedores y Kubernetes
