Con la lista corta de tecnologías cerrada, escribimos por fin un servicio completo. Empezamos por servicio-catalogo, el primero que sale del monolito según el orden de extracción de 02-02: no participa en la saga, solo lee, y su base de datos es MongoDB, de modo que podemos concentrarnos en la anatomía de un microservicio sin distraernos con transacciones ni eventos. Al terminar tendrás un proyecto Node.js 20 + Express ejecutable en el puerto 3001 que cumple el contrato de GET /v1/productos fijado en 03-01 y 03-06, responde errores RFC 7807, expone /health/live y /health/ready, y se apaga de forma ordenada. Esa estructura será la misma que usaremos en 04-04 para servicio-pedidos y la que las pruebas de 04-05 explotarán.

Contenido

  1. Crear el proyecto y elegir dependencias
  2. Estructura por capas y por qué separar app de servidor
  3. Infraestructura: la conexión a MongoDB
  4. Repositorio y servicio de productos
  5. Las rutas: GET /v1/productos en sus tres formas
  6. Validación de entrada y manejo centralizado de errores
  7. Composición de la aplicación y arranque con apagado ordenado
  8. Ejecutar en local: MongoDB, semilla y pruebas con curl

  1. Crear el proyecto y elegir dependencias

En el repositorio techcorp/servicio-catalogo (multirepo, 04-01), la creación es la de cualquier proyecto Node:

mkdir servicio-catalogo && cd servicio-catalogo
echo "20" > .nvmrc && nvm use
npm init -y
npm install express mongodb pino pino-http zod @techcorp/comun-http
npm install --save-dev nodemon dotenv
Dependencia Para qué Por qué esta y no otra
express Servidor HTTP y enrutado Decisión de 04-01: el framework del monolito
mongodb Driver oficial de MongoDB Suficiente para lectura por id y por índice; sin ODM (Mongoose) para no añadir una capa que no necesitamos
pino + pino-http Logger JSON mínimo y una línea de log por petición El más rápido del ecosistema; el formato completo es de 06-01
zod Validar y tipar la entrada (query, params) Declarativo y legible; la alternativa es la validación manual de 03-01
@techcorp/comun-http responderProblema, crearRutasSalud, middlewareRequestId, middlewareErrores, ErrorNegocio, crearLogger La librería de 04-01
dotenv, nodemon (dev) Cargar .env en local; reinicio automático al guardar Solo desarrollo; en producción las variables las pone el entorno (04-03)

El package.json resultante, con los scripts acordados en la plantilla:

{
  "name": "@techcorp/servicio-catalogo",
  "version": "0.1.0",
  "private": true,
  "engines": { "node": ">=20" },
  "scripts": {
    "start": "node src/servidor.js",
    "dev": "nodemon -r dotenv/config src/servidor.js",
    "test": "jest",
    "semilla": "node -r dotenv/config scripts/semilla.js"
  }
}

start es lo que ejecutará el contenedor (05-01): Node puro, sin dotenv ni nodemon. dev precarga dotenv (-r dotenv/config) para leer un .env local. test se rellena en 04-05.

  1. Estructura por capas y por qué separar app de servidor

servicio-catalogo/
├── src/
│   ├── servidor.js                  # arranque: config, conexiones, listen, SIGTERM
│   ├── app.js                       # compone Express: middlewares, rutas, errores (SIN listen)
│   ├── config.js                    # variables de entorno validadas (versión mínima; se amplía en 04-03)
│   ├── salud.js                     # comprobaciones para crearRutasSalud (cuando hay varias dependencias)
│   ├── rutas/productos.js           # HTTP ⇄ servicio (parsear, validar, responder)
│   ├── servicios/productosServicio.js        # casos de uso: lote por ids, detalle, listado paginado
│   ├── repositorios/productosRepositorio.js  # acceso a MongoDB
│   └── infra/mongo.js               # conexión, ping, cierre
├── scripts/semilla.js               # datos de ejemplo (p-501, p-777)
├── contratos/openapi.yaml           # el contrato de 03-01/03-06 (design-first)
├── .env.ejemplo
└── package.json

Las capas se hablan hacia dentro: la ruta conoce el servicio, el servicio conoce el repositorio, el repositorio conoce MongoDB; nunca al revés. Cada capa recibe sus dependencias por parámetro (inyección de dependencias sin ningún framework: funciones que reciben objetos). Esto tiene un motivo práctico que veremos en 04-05: para probar la ruta no hace falta MongoDB, se le pasa un repositorio en memoria.

Por qué app.js y servidor.js van separados. app.js exporta una función crearApp(dependencias) que devuelve la aplicación Express configurada pero sin escuchar en ningún puerto. servidor.js es el único que conecta con el mundo real: lee la configuración, abre MongoDB, llama a crearApp y hace listen. La consecuencia es que Supertest (04-05) puede ejecutar peticiones contra crearApp({ repositorio: enMemoria }) sin abrir puertos ni bases de datos, y que la misma app puede montarse en las pruebas de contrato con Pact. Si listen estuviera dentro de app.js, cada require arrancaría un servidor.

  1. Infraestructura: la conexión a MongoDB

// src/infra/mongo.js
const { MongoClient } = require('mongodb');

// Una conexión (en realidad un pool gestionado por el driver) para todo el proceso.
async function conectarMongo({ url, nombreBd, logger }) {
  const cliente = new MongoClient(url, {
    serverSelectionTimeoutMS: 5000,   // si Mongo no aparece en 5 s al arrancar, fallamos rápido
    maxPoolSize: 20                   // conexiones simultáneas máximas de esta réplica
  });
  await cliente.connect();
  logger.info({ nombreBd }, 'conectado a MongoDB');
  const bd = cliente.db(nombreBd);
  return {
    bd,
    ping: () => bd.command({ ping: 1 }),   // comprobación barata para /health/ready
    cerrar: () => cliente.close()          // cierre ordenado: espera a las operaciones en curso
  };
}

module.exports = { conectarMongo };

Devolvemos un objeto pequeño (bd, ping, cerrar) en lugar del MongoClient completo: el resto del código solo necesita eso, y en 04-05 se sustituye por un doble sin tocar nada más.

  1. Repositorio y servicio de productos

El repositorio traduce entre documentos de MongoDB (el esquema de 02-04: _id = productoId, nombre, precio, publicado, categoria, atributos...) y el modelo del servicio. Es el único fichero que sabe que existe MongoDB.

// src/repositorios/productosRepositorio.js
function crearProductosRepositorio(bd) {
  const coleccion = bd.collection('productos');
  // Proyección: solo los campos que la API pública expone (03-01). El resto no sale de aquí.
  const PROYECCION = { _id: 1, nombre: 1, precio: 1, publicado: 1, categoria: 1 };

  return {
    // Lote por ids: una sola consulta con $in (máx. 100, lo limita la ruta)
    async buscarPorIds(ids) {
      return coleccion.find({ _id: { $in: ids } }, { projection: PROYECCION }).toArray();
    },
    async obtenerPorId(id) {
      return coleccion.findOne({ _id: id }, { projection: PROYECCION });
    },
    // Paginación por cursor: "los `limite` siguientes a `despuesDeId` dentro de `categoria`".
    // Ordenamos por _id (único y estable): el cursor es simplemente el último _id devuelto.
    async listar({ categoria, despuesDeId, limite }) {
      const filtro = {};
      if (categoria) filtro.categoria = categoria;
      if (despuesDeId) filtro._id = { $gt: despuesDeId };
      // limite + 1 para saber si hay página siguiente sin una segunda consulta
      return coleccion.find(filtro, { projection: PROYECCION }).sort({ _id: 1 }).limit(limite + 1).toArray();
    },
    // Índices que este servicio necesita; createIndex es idempotente
    async asegurarIndices() {
      await coleccion.createIndex({ categoria: 1, _id: 1 });
    }
  };
}

module.exports = { crearProductosRepositorio };

El servicio contiene los casos de uso y la traducción al contrato público ({ id, nombre, precio, moneda, disponible }). Aquí no hay HTTP ni MongoDB: recibe y devuelve objetos JavaScript, y lanza errores de negocio con codigo.

// src/servicios/productosServicio.js
const { ErrorNegocio } = require('@techcorp/comun-http');

// Documento de Mongo → representación pública del contrato de 03-01
function aDto(doc) {
  return { id: doc._id, nombre: doc.nombre, precio: doc.precio, moneda: 'EUR', disponible: doc.publicado === true };
}

// Cursor opaco: base64url de un JSON con el último id. Opaco para el cliente, legible para nosotros.
const codificarCursor = (id) => Buffer.from(JSON.stringify({ id })).toString('base64url');
function decodificarCursor(cursor) {
  try { return JSON.parse(Buffer.from(cursor, 'base64url').toString()).id; }
  catch { throw new ErrorNegocio('DATOS_NO_VALIDOS', 'cursor no válido', 422); }
}

function crearProductosServicio({ repositorio }) {
  return {
    // GET /v1/productos?ids=  → { datos, noEncontrados }
    async obtenerLote(ids) {
      const docs = await repositorio.buscarPorIds(ids);
      const encontrados = new Set(docs.map((d) => d._id));
      return { datos: docs.map(aDto), noEncontrados: ids.filter((id) => !encontrados.has(id)) };
    },
    // GET /v1/productos/{id} → dto o PRODUCTO_NO_EXISTE (404)
    async obtener(id) {
      const doc = await repositorio.obtenerPorId(id);
      if (!doc) throw new ErrorNegocio('PRODUCTO_NO_EXISTE', `No existe el producto ${id}`, 404);
      return aDto(doc);
    },
    // GET /v1/productos?categoria=&cursor=&limite= → { datos, paginacion }
    async listar({ categoria, cursor, limite }) {
      const despuesDeId = cursor ? decodificarCursor(cursor) : undefined;
      const docs = await repositorio.listar({ categoria, despuesDeId, limite });
      const haySiguiente = docs.length > limite;
      const pagina = haySiguiente ? docs.slice(0, limite) : docs;
      return {
        datos: pagina.map(aDto),
        paginacion: { limite, haySiguiente, siguienteCursor: haySiguiente ? codificarCursor(pagina.at(-1)._id) : null }
      };
    }
  };
}

module.exports = { crearProductosServicio };

ErrorNegocio es una clase mínima de @techcorp/comun-http (class ErrorNegocio extends Error { constructor(codigo, mensaje, status = 422) {...} }) cuyo único cometido es llevar codigo y status hasta el middleware de errores. Con ella, el servicio expresa "no existe" sin saber que eso será un 404.

  1. Las rutas: GET /v1/productos en sus tres formas

La ruta hace tres cosas y solo tres: parsear y validar la petición, llamar al servicio, y traducir el resultado a HTTP (códigos, cabeceras). El mismo path /v1/productos sirve el lote (si viene ids) o el listado paginado (si no).

// src/rutas/productos.js
const express = require('express');
const { z } = require('zod');

// Esquemas de entrada (zod). Se validan ANTES de tocar el servicio.
const esquemaLote = z.object({
  ids: z.string().min(1)
        .transform((s) => [...new Set(s.split(',').map((x) => x.trim()).filter(Boolean))])   // "p-501, p-777" → ['p-501','p-777'] sin duplicados
        .refine((arr) => arr.length <= 100, { message: 'máximo 100 ids por petición' })
});
const esquemaListado = z.object({
  categoria: z.string().min(1).max(50).optional(),
  cursor: z.string().max(200).optional(),
  limite: z.coerce.number().int().min(1).max(100).default(20)     // coerce: "20" (texto de la query) → 20
});
const esquemaId = z.object({ id: z.string().regex(/^p-\d+$/, 'id de producto con formato p-<n>') });

function crearRutasProductos({ productosServicio }) {
  const enrutador = express.Router();

  enrutador.get('/v1/productos', async (req, res, next) => {
    try {
      if (req.query.ids !== undefined) {
        // ---- Forma 1: lote por ids (03-01 §5.3) ----
        const { ids } = esquemaLote.parse(req.query);      // lanza ZodError → 400 en el middleware de errores
        const resultado = await productosServicio.obtenerLote(ids);
        return res.set('Cache-Control', 'public, max-age=30').json(resultado);
      }
      // ---- Forma 2: listado paginado por cursor ----
      const filtros = esquemaListado.parse(req.query);
      const resultado = await productosServicio.listar(filtros);
      const enlaces = { self: { href: req.originalUrl } };
      if (resultado.paginacion.haySiguiente) {   // HATEOAS mínimo (03-01): el enlace a la página siguiente ya construido
        enlaces.siguiente = { href: `/v1/productos?${new URLSearchParams({ ...req.query, limite: String(filtros.limite), cursor: resultado.paginacion.siguienteCursor })}` };
      }
      res.set('Cache-Control', 'public, max-age=30').json({ ...resultado, _links: enlaces });
    } catch (err) { next(err); }
  });

  enrutador.get('/v1/productos/:id', async (req, res, next) => {
    try {
      // ---- Forma 3: detalle ----
      const { id } = esquemaId.parse(req.params);
      const producto = await productosServicio.obtener(id);   // lanza PRODUCTO_NO_EXISTE → 404
      res.set('Cache-Control', 'public, max-age=30').json({ ...producto, _links: { self: { href: `/v1/productos/${id}` } } });
    } catch (err) { next(err); }
  });

  return enrutador;
}

module.exports = { crearRutasProductos };

Detalles que conviene subrayar: /v1/ está en la ruta desde el primer día (03-06; el gateway reenvía /api/v1/productos/* a servicio-catalogo:3001/v1/productos/*); todo error va a next(err), la ruta no formatea errores (apartado 6), así el formato es idéntico en todos los endpoints y servicios; Cache-Control: public, max-age=30 es la cabecera prometida en el contrato; y el límite de 100 ids vive en el esquema de validación, no en el repositorio, porque es una regla del contrato HTTP.

  1. Validación de entrada y manejo centralizado de errores

Hay tres familias de errores y el middleware final de Express las convierte todas al mismo formato RFC 7807 de 03-01, sin filtrar internos y con el X-Request-Id para poder buscarlo en los logs:

Origen Cómo llega Respuesta
Entrada inválida ZodError lanzado por parse() 400 PETICION_INVALIDA con lista errores: [{campo, mensaje}]
Regla de negocio ErrorNegocio con codigo y status El status y codigo del error (404 PRODUCTO_NO_EXISTE, 422 DATOS_NO_VALIDOS)
Cualquier otra excepción Error genérico (Mongo caído, bug) 500 ERROR_INTERNO con detalle genérico; el error completo va al log

Así está implementado en @techcorp/comun-http (los servicios solo lo importan, pero conviene entenderlo):

// @techcorp/comun-http — middlewareErrores.js
const { responderProblema } = require('./responderProblema');   // el helper de 03-01

function middlewareErrores({ logger }) {
  return (err, req, res, _next) => {                             // 4 parámetros: así reconoce Express un middleware de error
    if (err.name === 'ZodError') {
      const errores = err.issues.map((i) => ({ campo: i.path.join('.') || '(query)', mensaje: i.message }));
      return responderProblema(res, req, { status: 400, codigo: 'PETICION_INVALIDA', detail: `${errores.length} campo(s) no válido(s)`, errores });
    }
    if (err.codigo && err.status) return responderProblema(res, req, { status: err.status, codigo: err.codigo, detail: err.message }); // ErrorNegocio
    if (err.type === 'entity.parse.failed') return responderProblema(res, req, { status: 400, codigo: 'PETICION_INVALIDA', detail: 'JSON mal formado' });
    (req.log ?? logger).error({ err, requestId: req.id }, 'error no controlado');   // log completo; al cliente, nada interno
    responderProblema(res, req, { status: 500, codigo: 'ERROR_INTERNO', detail: `Error interno. Referencia: ${req.id}` });
  };
}
module.exports = { middlewareErrores };

Como responderProblema (03-01) rellena type, title, status, detail, codigo e instance, y el middlewareRequestId ya ha puesto X-Request-Id en la respuesta, un GET /v1/productos/p-999 devuelve exactamente lo que el contrato promete: 404, Content-Type: application/problem+json y el cuerpo {"type": "https://techcorp.example/errores/producto-no-existe", "title": "Error en la petición", "status": 404, "detail": "No existe el producto p-999", "codigo": "PRODUCTO_NO_EXISTE", "instance": "/v1/productos/p-999"}.

  1. Composición de la aplicación y arranque con apagado ordenado

app.js monta las piezas en el orden correcto (el orden de los middlewares en Express importa: request-id y logging antes de las rutas; el de errores, el último):

// src/app.js
const express = require('express');
const pinoHttp = require('pino-http');
const { middlewareRequestId, middlewareErrores, crearRutasSalud, responderProblema } = require('@techcorp/comun-http');
const { crearRutasProductos } = require('./rutas/productos');
const { crearProductosServicio } = require('./servicios/productosServicio');

// Recibe las dependencias ya construidas: así las pruebas pueden pasar un repositorio en memoria
function crearApp({ repositorio, logger, comprobacionesSalud = {} }) {
  const app = express();
  app.disable('x-powered-by');                                   // no anunciamos la tecnología
  app.use(middlewareRequestId());                                // 1. X-Request-Id en req.id y en la respuesta
  app.use(pinoHttp({ logger, genReqId: (req) => req.id }));      // 2. una línea de log por petición, con el mismo id
  app.use(express.json({ limit: '100kb' }));                     // 3. cuerpo JSON (Catálogo aún no lo usa; la plantilla lo trae)
  app.use(crearRutasSalud({ comprobaciones: comprobacionesSalud }));   // 4. /health/live y /health/ready (03-05)

  const productosServicio = crearProductosServicio({ repositorio });
  app.use(crearRutasProductos({ productosServicio }));           // 5. la API

  app.use((req, res) => responderProblema(res, req, { status: 404, codigo: 'RUTA_NO_EXISTE', detail: `No existe ${req.method} ${req.originalUrl}` }));
  app.use(middlewareErrores({ logger }));                        // 6. SIEMPRE el último
  return app;
}

module.exports = { crearApp };

Y servidor.js, el único fichero con efectos sobre el mundo (puerto, base de datos, señales):

// src/servidor.js
const { crearLogger } = require('@techcorp/comun-http');
const { config } = require('./config');                 // { PUERTO, MONGO_URL, MONGO_BD, LOG_NIVEL } validados; 04-03 lo amplía
const { conectarMongo } = require('./infra/mongo');
const { crearProductosRepositorio } = require('./repositorios/productosRepositorio');
const { crearApp } = require('./app');

async function arrancar() {
  const logger = crearLogger({ servicio: 'servicio-catalogo', nivel: config.LOG_NIVEL });

  // 1. Dependencias externas primero: si Mongo no está, el proceso muere y Kubernetes lo reintentará
  const mongo = await conectarMongo({ url: config.MONGO_URL, nombreBd: config.MONGO_BD, logger });
  const repositorio = crearProductosRepositorio(mongo.bd);
  await repositorio.asegurarIndices();

  // 2. Componer la app con sus dependencias reales
  const app = crearApp({ repositorio, logger, comprobacionesSalud: { mongodb: mongo.ping } });

  // 3. Escuchar
  const servidor = app.listen(config.PUERTO, () => logger.info({ puerto: config.PUERTO }, 'servicio-catalogo escuchando'));
  servidor.keepAliveTimeout = 65000;                    // > timeout del balanceador (60 s típico): evita cortes en keep-alive

  // 4. Apagado ordenado. Kubernetes envía SIGTERM y espera (30 s por defecto) antes de SIGKILL.
  //    crearRutasSalud ya ha puesto /health/ready en 503 al recibir SIGTERM (03-05): dejan de llegar peticiones nuevas.
  const apagar = (senal) => {
    logger.info({ senal }, 'apagado iniciado');
    servidor.close(async () => {                        // deja de aceptar conexiones; espera a las peticiones en curso
      await mongo.cerrar();
      logger.info('apagado completado');
      process.exit(0);
    });
    setTimeout(() => { logger.error('apagado forzado por timeout'); process.exit(1); }, 10000).unref();
  };
  process.on('SIGTERM', () => apagar('SIGTERM'));
  process.on('SIGINT', () => apagar('SIGINT'));         // Ctrl+C en local
}

arrancar().catch((err) => { console.error('no se pudo arrancar', err); process.exit(1); });

La secuencia de apagado (SIGTERM → /health/ready en 503 → el balanceador deja de enviar peticiones → servidor.close() termina las peticiones en curso → cierre de Mongo → exit(0), con un límite de seguridad de 10 s) es la que evita perder peticiones en cada despliegue; en 05-04 veremos cómo encaja con los rolling updates de Kubernetes.

src/config.js es, por ahora, mínimo: lee PUERTO (por defecto 3001), MONGO_URL (por defecto mongodb://localhost:27017), MONGO_BD (catalogo) y LOG_NIVEL (info) de process.env y falla si MONGO_URL no es una URL válida. La versión completa, con zod y todos los tipos de configuración, es el tema de 04-03.

  1. Ejecutar en local: MongoDB, semilla y pruebas con curl

MongoDB en un contenedor (una línea; Docker en profundidad es 05-01) y el .env local, copiado de .env.ejemplo (que sí está en git; .env no):

docker run -d --name mongo-catalogo -p 27017:27017 mongo:7
cp .env.ejemplo .env     # PUERTO=3001  MONGO_URL=mongodb://localhost:27017  MONGO_BD=catalogo  LOG_NIVEL=debug

Semilla de datos con los productos de todo el curso. Es idempotente (upsert), así se puede ejecutar tantas veces como se quiera:

// scripts/semilla.js
const { MongoClient } = require('mongodb');

const PRODUCTOS = [
  { _id: 'p-501', productoId: 'p-501', nombre: 'Auriculares BT X200', categoria: 'audio', precio: 59.90, publicado: true,
    descripcion: 'Auriculares inalámbricos con cancelación de ruido', atributos: { color: 'negro', autonomiaHoras: 30, bluetooth: '5.3' } },
  { _id: 'p-777', productoId: 'p-777', nombre: 'Cable USB-C 2 m', categoria: 'accesorios', precio: 9.90, publicado: true,
    descripcion: 'Cable USB-C a USB-C, 100 W', atributos: { longitudMetros: 2, potenciaW: 100 } },
  { _id: 'p-802', productoId: 'p-802', nombre: 'Altavoz portátil S10', categoria: 'audio', precio: 34.50, publicado: false, atributos: { color: 'azul' } }
];

async function main() {
  const cliente = new MongoClient(process.env.MONGO_URL ?? 'mongodb://localhost:27017');
  await cliente.connect();
  const coleccion = cliente.db(process.env.MONGO_BD ?? 'catalogo').collection('productos');
  for (const p of PRODUCTOS) {
    await coleccion.updateOne({ _id: p._id }, { $set: { ...p, actualizadoEn: new Date().toISOString() } }, { upsert: true });
  }
  console.log(`semilla aplicada: ${PRODUCTOS.length} productos`);
  await cliente.close();
}
main().catch((e) => { console.error(e); process.exit(1); });
npm run semilla     # semilla aplicada: 3 productos
npm run dev         # {"level":30,"servicio":"servicio-catalogo","puerto":3001,"msg":"servicio-catalogo escuchando"}

Pruebas manuales y lo que debe salir:

# 1. Lote (el contrato que consumirá Pedidos en 04-04). p-999 no existe → va a noEncontrados, no es 404
curl -s -i "http://localhost:3001/v1/productos?ids=p-501,p-777,p-999"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=30
X-Request-Id: req-01J5A2X7Q0K3

{"datos":[{"id":"p-501","nombre":"Auriculares BT X200","precio":59.9,"moneda":"EUR","disponible":true},
          {"id":"p-777","nombre":"Cable USB-C 2 m","precio":9.9,"moneda":"EUR","disponible":true}],
 "noEncontrados":["p-999"]}
# 2. Detalle inexistente → 404 application/problem+json, codigo PRODUCTO_NO_EXISTE (el JSON del apartado 6)
curl -s -i http://localhost:3001/v1/productos/p-999 | head -2
# 3. Listado paginado: 1 por página, categoría audio (p-501 y p-802; p-802 sale con disponible=false)
curl -s "http://localhost:3001/v1/productos?categoria=audio&limite=1"
# {"datos":[{"id":"p-501",...}],"paginacion":{"limite":1,"haySiguiente":true,"siguienteCursor":"eyJpZCI6InAtNTAxIn0"},"_links":{...,"siguiente":{"href":"/v1/productos?categoria=audio&limite=1&cursor=eyJpZCI6InAtNTAxIn0"}}}
# 4. Validación: limite=500 (o 101 ids) → 400 PETICION_INVALIDA
curl -s "http://localhost:3001/v1/productos?limite=500" | jq .codigo     # "PETICION_INVALIDA"
# 5. Salud: listo con Mongo; 503 sin Mongo
curl -s http://localhost:3001/health/ready      # {"estado":"listo","dependencias":{"mongodb":"ok"}}
docker stop mongo-catalogo && curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3001/health/ready   # 503
docker start mongo-catalogo
# 6. Apagado ordenado: Ctrl+C en la terminal de npm run dev → "apagado iniciado" ... "apagado completado"

Si los seis pasos responden así, servicio-catalogo cumple su contrato y está listo para que Pedidos lo consuma. Un fichero peticiones.http con estas mismas llamadas, para la extensión REST Client de VS Code, acompaña al repositorio.

Errores Comunes y Consejos

  • app.listen dentro de app.js. Rompe la testabilidad y hace que cada require abra un puerto. crearApp() devuelve, servidor.js escucha.
  • Rutas que hablan con MongoDB directamente. Funciona hasta que hay que probar sin base de datos o cambiar el driver. Ruta → servicio → repositorio, siempre.
  • Formatear errores en cada ruta. Se acaba con cinco formatos distintos. next(err) y un único middlewareErrores.
  • Olvidar el try/catch en un manejador async. Express 4 no captura promesas rechazadas: la petición se queda colgada. Envuelve siempre (o usa express-async-errors, o Express 5, que sí lo hace).
  • Devolver el documento de MongoDB tal cual. Expone campos internos y convierte tu esquema de BD en contrato público. Siempre a través de aDto.
  • Ignorar SIGTERM. Node por defecto muere en seco: las peticiones en curso reciben reset. servidor.close() + cierre de conexiones + timeout de seguridad.
  • Sin timeout de selección de servidor en Mongo (arranque con Mongo caído: 30 s en silencio) y semillas no idempotentes (insertMany falla la segunda vez). serverSelectionTimeoutMS y upsert.

Ejercicios

Ejercicio 1. El equipo de Experiencia de compra pide que el lote GET /v1/productos?ids= no devuelva los productos con publicado: false en datos, sino que los incluya en noEncontrados (para Pedidos, "no publicado" y "no existe" significan lo mismo: no se puede vender). Modifica el servicio (no la ruta ni el repositorio) para conseguirlo y explica por qué la capa correcta es esa.

Ejercicio 2. Escribe una versión en memoria del repositorio (crearProductosRepositorioEnMemoria(productos)) que implemente buscarPorIds, obtenerPorId, listar y asegurarIndices sobre un array. Después, muestra cómo se llamaría a crearApp con él y con un logger silencioso (pino({ level: 'silent' })). Es la base que 04-05 usará.

Ejercicio 3. Un compañero propone que /health/ready de Catálogo compruebe también que el gateway (puerto 8080) responde, "para asegurarnos de que el sistema funciona". Explica, con la tabla de 03-05, por qué es una mala idea y qué pasaría con las réplicas de Catálogo si el gateway cayera.

Soluciones

Solución 1.

async obtenerLote(ids) {
  const docs = await repositorio.buscarPorIds(ids);
  const vendibles = docs.filter((d) => d.publicado === true);
  const encontrados = new Set(vendibles.map((d) => d._id));
  return { datos: vendibles.map(aDto), noEncontrados: ids.filter((id) => !encontrados.has(id)) };
}

Es una regla de negocio del contrato ("en el lote solo se devuelven productos vendibles"), no una cuestión de HTTP (ruta) ni de almacenamiento (repositorio): el repositorio debe seguir pudiendo devolver no publicados para el detalle o el panel de administración. Conviene además actualizar contratos/openapi.yaml en la misma PR (03-06) y avisar a Pedidos, que interpretará esos ids como PRODUCTO_NO_DISPONIBLE.

Solución 2.

// pruebas/dobles/productosRepositorioEnMemoria.js
function crearProductosRepositorioEnMemoria(productos = []) {
  const datos = [...productos];
  return {
    async buscarPorIds(ids) { return datos.filter((p) => ids.includes(p._id)); },
    async obtenerPorId(id) { return datos.find((p) => p._id === id) ?? null; },
    async listar({ categoria, despuesDeId, limite }) {
      return datos.filter((p) => !categoria || p.categoria === categoria).filter((p) => !despuesDeId || p._id > despuesDeId)
                  .sort((a, b) => a._id.localeCompare(b._id)).slice(0, limite + 1);
    },
    async asegurarIndices() {}
  };
}
// uso: ningún cambio en app.js, rutas ni servicio; esa es la recompensa de la inyección de dependencias
const app = crearApp({ repositorio: crearProductosRepositorioEnMemoria([{ _id: 'p-501', nombre: 'Auriculares BT X200', precio: 59.90, publicado: true, categoria: 'audio' }]),
                       logger: require('pino')({ level: 'silent' }) });

Solución 3. /health/ready debe comprobar las dependencias propias del servicio, aquellas sin las cuales esta réplica no puede atender una petición: MongoDB. El gateway no es una dependencia de Catálogo (es al revés). Si se incluyera y el gateway cayera, todas las réplicas de Catálogo pasarían a "no listo", Kubernetes las sacaría del balanceo, y cuando el gateway volviera no tendría ningún Catálogo al que enviar tráfico hasta que las réplicas volvieran a ser ready: una caída parcial convertida en caída total y prolongada. Además, el BFF móvil y otros consumidores internos que no pasan por el gateway perderían Catálogo sin motivo.

Conclusión

Tenemos el primer microservicio de TechCorp funcionando: un proyecto Node.js 20 + Express con dependencias mínimas (express, mongodb, pino, zod, @techcorp/comun-http), una estructura por capas (rutas → servicio → repositorio → infraestructura) en la que cada capa recibe sus dependencias por parámetro, la separación entre crearApp() y servidor.js que hace posible probarlo sin puertos ni base de datos, los tres endpoints GET /v1/productos (lote con {datos, noEncontrados} y Cache-Control, detalle con 404 PRODUCTO_NO_EXISTE, listado paginado por cursor) fieles a los contratos de 03-01 y 03-06, validación con zod, un único middleware que convierte cualquier error en RFC 7807 con X-Request-Id, salud con crearRutasSalud, apagado ordenado ante SIGTERM, y una semilla con p-501 y p-777 para probarlo con curl.

Hay una pieza que hemos dejado deliberadamente pequeña: src/config.js. Un servicio que se ejecuta en el portátil, en staging y en producción, replicado y contenerizado, necesita una forma disciplinada de saber a qué MongoDB conectarse, qué puerto escuchar y qué funcionalidades activar, sin tocar el código y sin filtrar contraseñas. Esa disciplina, y el módulo config.js completo con validación fail-fast que reutilizarán todos los servicios, es la siguiente lección.

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