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
- Crear el proyecto y elegir dependencias
- Estructura por capas y por qué separar
appdeservidor - Infraestructura: la conexión a MongoDB
- Repositorio y servicio de productos
- Las rutas:
GET /v1/productosen sus tres formas - Validación de entrada y manejo centralizado de errores
- Composición de la aplicación y arranque con apagado ordenado
- Ejecutar en local: MongoDB, semilla y pruebas con
curl
- 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.
- Estructura por capas y por qué separar
app de servidor
app de servidorservicio-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.
- 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.
- 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.
- Las rutas:
GET /v1/productos en sus tres formas
GET /v1/productos en sus tres formasLa 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.
- 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"}.
- 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.
- Ejecutar en local: MongoDB, semilla y pruebas con
curl
curlMongoDB 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=debugSemilla 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.listendentro deapp.js. Rompe la testabilidad y hace que cadarequireabra un puerto.crearApp()devuelve,servidor.jsescucha.- 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 únicomiddlewareErrores. - Olvidar el
try/catchen un manejadorasync. Express 4 no captura promesas rechazadas: la petición se queda colgada. Envuelve siempre (o usaexpress-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 (
insertManyfalla la segunda vez).serverSelectionTimeoutMSyupsert.
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
- 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
