Amb la llista curta de tecnologies tancada, escrivim per fi un servei complet. Comencem per servei-cataleg, el primer que surt del monòlit segons l'ordre d'extracció de 02-02: no participa en la saga, només llegeix, i la seva base de dades és MongoDB, de manera que podem concentrar-nos en l'anatomia d'un microservei sense distreure'ns amb transaccions ni esdeveniments. En acabar tindràs un projecte Node.js 20 + Express executable al port 3001 que compleix el contracte de GET /v1/productes fixat a 03-01 i 03-06, respon errors RFC 7807, exposa /health/live i /health/ready, i s'atura de manera ordenada. Aquesta estructura serà la mateixa que farem servir a 04-04 per a servei-comandes i la que les proves de 04-05 explotaran.

Contingut

  1. Crear el projecte i triar dependències
  2. Estructura per capes i per què separar app de servidor
  3. Infraestructura: la connexió a MongoDB
  4. Repositori i servei de productes
  5. Les rutes: GET /v1/productes en les seves tres formes
  6. Validació d'entrada i gestió centralitzada d'errors
  7. Composició de l'aplicació i arrencada amb aturada ordenada
  8. Executar en local: MongoDB, llavor i proves amb curl

  1. Crear el projecte i triar dependències

Al repositori techcorp/servei-cataleg (multirepo, 04-01), la creació és la de qualsevol projecte Node:

mkdir servei-cataleg && cd servei-cataleg
echo "20" > .nvmrc && nvm use
npm init -y
npm install express mongodb pino pino-http zod @techcorp/comu-http
npm install --save-dev nodemon dotenv
Dependència Per a què Per què aquesta i no una altra
express Servidor HTTP i encaminament Decisió de 04-01: el framework del monòlit
mongodb Driver oficial de MongoDB Suficient per a lectura per id i per índex; sense ODM (Mongoose) per no afegir una capa que no necessitem
pino + pino-http Logger JSON mínim i una línia de log per petició El més ràpid de l'ecosistema; el format complet és de 06-01
zod Validar i tipar l'entrada (query, params) Declaratiu i llegible; l'alternativa és la validació manual de 03-01
@techcorp/comu-http respondreProblema, crearRutesSalut, middlewareRequestId, middlewareErrors, ErrorNegoci, crearLogger La llibreria de 04-01
dotenv, nodemon (dev) Carregar .env en local; reinici automàtic en desar Només desenvolupament; en producció les variables les posa l'entorn (04-03)

El package.json resultant, amb els scripts acordats a la plantilla:

{
  "name": "@techcorp/servei-cataleg",
  "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",
    "llavor": "node -r dotenv/config scripts/llavor.js"
  }
}

start és el que executarà el contenidor (05-01): Node pur, sense dotenv ni nodemon. dev precarrega dotenv (-r dotenv/config) per llegir un .env local. test s'omple a 04-05.

  1. Estructura per capes i per què separar app de servidor

servei-cataleg/
├── src/
│   ├── servidor.js                  # arrencada: config, connexions, listen, SIGTERM
│   ├── app.js                       # compon Express: middlewares, rutes, errors (SENSE listen)
│   ├── config.js                    # variables d'entorn validades (versió mínima; s'amplia a 04-03)
│   ├── salut.js                     # comprovacions per a crearRutesSalut (quan hi ha diverses dependències)
│   ├── rutes/productes.js           # HTTP ⇄ servei (parsejar, validar, respondre)
│   ├── serveis/productesServei.js            # casos d'ús: lot per ids, detall, llistat paginat
│   ├── repositoris/productesRepositori.js    # accés a MongoDB
│   └── infra/mongo.js               # connexió, ping, tancament
├── scripts/llavor.js                # dades d'exemple (p-501, p-777)
├── contractes/openapi.yaml          # el contracte de 03-01/03-06 (design-first)
├── .env.exemple
└── package.json

Les capes es parlen cap endins: la ruta coneix el servei, el servei coneix el repositori, el repositori coneix MongoDB; mai al revés. Cada capa rep les seves dependències per paràmetre (injecció de dependències sense cap framework: funcions que reben objectes). Això té un motiu pràctic que veurem a 04-05: per provar la ruta no cal MongoDB, se li passa un repositori en memòria.

Per què app.js i servidor.js van separats. app.js exporta una funció crearApp(dependencies) que retorna l'aplicació Express configurada però sense escoltar en cap port. servidor.js és l'únic que connecta amb el món real: llegeix la configuració, obre MongoDB, crida crearApp i fa listen. La conseqüència és que Supertest (04-05) pot executar peticions contra crearApp({ repositori: enMemoria }) sense obrir ports ni bases de dades, i que la mateixa app es pot muntar a les proves de contracte amb Pact. Si listen fos dins d'app.js, cada require arrencaria un servidor.

  1. Infraestructura: la connexió a MongoDB

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

// Una connexió (en realitat un pool gestionat pel driver) per a tot el procés.
async function connectarMongo({ url, nomBd, logger }) {
  const client = new MongoClient(url, {
    serverSelectionTimeoutMS: 5000,   // si Mongo no apareix en 5 s en arrencar, fallem de pressa
    maxPoolSize: 20                   // connexions simultànies màximes d'aquesta rèplica
  });
  await client.connect();
  logger.info({ nomBd }, 'connectat a MongoDB');
  const bd = client.db(nomBd);
  return {
    bd,
    ping: () => bd.command({ ping: 1 }),   // comprovació barata per a /health/ready
    tancar: () => client.close()           // tancament ordenat: espera les operacions en curs
  };
}

module.exports = { connectarMongo };

Retornem un objecte petit (bd, ping, tancar) en lloc del MongoClient complet: la resta del codi només necessita això, i a 04-05 se substitueix per un doble sense tocar res més.

  1. Repositori i servei de productes

El repositori tradueix entre documents de MongoDB (l'esquema de 02-04: _id = producteId, nom, preu, publicat, categoria, atributs...) i el model del servei. És l'únic fitxer que sap que existeix MongoDB.

// src/repositoris/productesRepositori.js
function crearProductesRepositori(bd) {
  const colleccio = bd.collection('productes');
  // Projecció: només els camps que l'API pública exposa (03-01). La resta no surt d'aquí.
  const PROJECCIO = { _id: 1, nom: 1, preu: 1, publicat: 1, categoria: 1 };

  return {
    // Lot per ids: una sola consulta amb $in (màx. 100, ho limita la ruta)
    async cercarPerIds(ids) {
      return colleccio.find({ _id: { $in: ids } }, { projection: PROJECCIO }).toArray();
    },
    async obtenirPerId(id) {
      return colleccio.findOne({ _id: id }, { projection: PROJECCIO });
    },
    // Paginació per cursor: "els `limit` següents a `despresDeId` dins de `categoria`".
    // Ordenem per _id (únic i estable): el cursor és simplement l'últim _id retornat.
    async llistar({ categoria, despresDeId, limit }) {
      const filtre = {};
      if (categoria) filtre.categoria = categoria;
      if (despresDeId) filtre._id = { $gt: despresDeId };
      // limit + 1 per saber si hi ha pàgina següent sense una segona consulta
      return colleccio.find(filtre, { projection: PROJECCIO }).sort({ _id: 1 }).limit(limit + 1).toArray();
    },
    // Índexs que aquest servei necessita; createIndex és idempotent
    async assegurarIndexs() {
      await colleccio.createIndex({ categoria: 1, _id: 1 });
    }
  };
}

module.exports = { crearProductesRepositori };

El servei conté els casos d'ús i la traducció al contracte públic ({ id, nom, preu, moneda, disponible }). Aquí no hi ha HTTP ni MongoDB: rep i retorna objectes JavaScript, i llança errors de negoci amb codi.

// src/serveis/productesServei.js
const { ErrorNegoci } = require('@techcorp/comu-http');

// Document de Mongo → representació pública del contracte de 03-01
function aDto(doc) {
  return { id: doc._id, nom: doc.nom, preu: doc.preu, moneda: 'EUR', disponible: doc.publicat === true };
}

// Cursor opac: base64url d'un JSON amb l'últim id. Opac per al client, llegible per a nosaltres.
const codificarCursor = (id) => Buffer.from(JSON.stringify({ id })).toString('base64url');
function descodificarCursor(cursor) {
  try { return JSON.parse(Buffer.from(cursor, 'base64url').toString()).id; }
  catch { throw new ErrorNegoci('DADES_NO_VALIDES', 'cursor no vàlid', 422); }
}

function crearProductesServei({ repositori }) {
  return {
    // GET /v1/productes?ids=  → { dades, noTrobats }
    async obtenirLot(ids) {
      const docs = await repositori.cercarPerIds(ids);
      const trobats = new Set(docs.map((d) => d._id));
      return { dades: docs.map(aDto), noTrobats: ids.filter((id) => !trobats.has(id)) };
    },
    // GET /v1/productes/{id} → dto o PRODUCTE_NO_EXISTEIX (404)
    async obtenir(id) {
      const doc = await repositori.obtenirPerId(id);
      if (!doc) throw new ErrorNegoci('PRODUCTE_NO_EXISTEIX', `No existeix el producte ${id}`, 404);
      return aDto(doc);
    },
    // GET /v1/productes?categoria=&cursor=&limit= → { dades, paginacio }
    async llistar({ categoria, cursor, limit }) {
      const despresDeId = cursor ? descodificarCursor(cursor) : undefined;
      const docs = await repositori.llistar({ categoria, despresDeId, limit });
      const hiHaSeguent = docs.length > limit;
      const pagina = hiHaSeguent ? docs.slice(0, limit) : docs;
      return {
        dades: pagina.map(aDto),
        paginacio: { limit, hiHaSeguent, seguentCursor: hiHaSeguent ? codificarCursor(pagina.at(-1)._id) : null }
      };
    }
  };
}

module.exports = { crearProductesServei };

ErrorNegoci és una classe mínima de @techcorp/comu-http (class ErrorNegoci extends Error { constructor(codi, missatge, status = 422) {...} }) que té com a única comesa portar codi i status fins al middleware d'errors. Amb ella, el servei expressa "no existeix" sense saber que això serà un 404.

  1. Les rutes: GET /v1/productes en les seves tres formes

La ruta fa tres coses i només tres: parsejar i validar la petició, cridar el servei, i traduir el resultat a HTTP (codis, capçaleres). El mateix path /v1/productes serveix el lot (si arriba ids) o el llistat paginat (si no).

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

// Esquemes d'entrada (zod). Es validen ABANS de tocar el servei.
const esquemaLot = 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'] sense duplicats
        .refine((arr) => arr.length <= 100, { message: 'màxim 100 ids per petició' })
});
const esquemaLlistat = z.object({
  categoria: z.string().min(1).max(50).optional(),
  cursor: z.string().max(200).optional(),
  limit: z.coerce.number().int().min(1).max(100).default(20)     // coerce: "20" (text de la query) → 20
});
const esquemaId = z.object({ id: z.string().regex(/^p-\d+$/, 'id de producte amb format p-<n>') });

function crearRutesProductes({ productesServei }) {
  const encaminador = express.Router();

  encaminador.get('/v1/productes', async (req, res, next) => {
    try {
      if (req.query.ids !== undefined) {
        // ---- Forma 1: lot per ids (03-01 §5.3) ----
        const { ids } = esquemaLot.parse(req.query);       // llança ZodError → 400 al middleware d'errors
        const resultat = await productesServei.obtenirLot(ids);
        return res.set('Cache-Control', 'public, max-age=30').json(resultat);
      }
      // ---- Forma 2: llistat paginat per cursor ----
      const filtres = esquemaLlistat.parse(req.query);
      const resultat = await productesServei.llistar(filtres);
      const enllacos = { self: { href: req.originalUrl } };
      if (resultat.paginacio.hiHaSeguent) {   // HATEOAS mínim (03-01): l'enllaç a la pàgina següent ja construït
        enllacos.seguent = { href: `/v1/productes?${new URLSearchParams({ ...req.query, limit: String(filtres.limit), cursor: resultat.paginacio.seguentCursor })}` };
      }
      res.set('Cache-Control', 'public, max-age=30').json({ ...resultat, _links: enllacos });
    } catch (err) { next(err); }
  });

  encaminador.get('/v1/productes/:id', async (req, res, next) => {
    try {
      // ---- Forma 3: detall ----
      const { id } = esquemaId.parse(req.params);
      const producte = await productesServei.obtenir(id);   // llança PRODUCTE_NO_EXISTEIX → 404
      res.set('Cache-Control', 'public, max-age=30').json({ ...producte, _links: { self: { href: `/v1/productes/${id}` } } });
    } catch (err) { next(err); }
  });

  return encaminador;
}

module.exports = { crearRutesProductes };

Detalls que convé subratllar: /v1/ és a la ruta des del primer dia (03-06; el gateway reenvia /api/v1/productes/* a servei-cataleg:3001/v1/productes/*); tots els errors van a next(err), la ruta no formata errors (apartat 6), així el format és idèntic a tots els endpoints i serveis; Cache-Control: public, max-age=30 és la capçalera promesa al contracte; i el límit de 100 ids viu a l'esquema de validació, no al repositori, perquè és una regla del contracte HTTP.

  1. Validació d'entrada i gestió centralitzada d'errors

Hi ha tres famílies d'errors i el middleware final d'Express les converteix totes al mateix format RFC 7807 de 03-01, sense filtrar interns i amb l'X-Request-Id per poder-lo buscar als logs:

Origen Com arriba Resposta
Entrada invàlida ZodError llançat per parse() 400 PETICIO_INVALIDA amb llista errors: [{camp, missatge}]
Regla de negoci ErrorNegoci amb codi i status El status i el codi de l'error (404 PRODUCTE_NO_EXISTEIX, 422 DADES_NO_VALIDES)
Qualsevol altra excepció Error genèric (Mongo caigut, bug) 500 ERROR_INTERN amb detall genèric; l'error complet va al log

Així està implementat a @techcorp/comu-http (els serveis només l'importen, però convé entendre'l):

// @techcorp/comu-http — middlewareErrors.js
const { respondreProblema } = require('./respondreProblema');   // el helper de 03-01

function middlewareErrors({ logger }) {
  return (err, req, res, _next) => {                             // 4 paràmetres: així reconeix Express un middleware d'error
    if (err.name === 'ZodError') {
      const errors = err.issues.map((i) => ({ camp: i.path.join('.') || '(query)', missatge: i.message }));
      return respondreProblema(res, req, { status: 400, codi: 'PETICIO_INVALIDA', detail: `${errors.length} camp(s) no vàlid(s)`, errors });
    }
    if (err.codi && err.status) return respondreProblema(res, req, { status: err.status, codi: err.codi, detail: err.message }); // ErrorNegoci
    if (err.type === 'entity.parse.failed') return respondreProblema(res, req, { status: 400, codi: 'PETICIO_INVALIDA', detail: 'JSON mal format' });
    (req.log ?? logger).error({ err, requestId: req.id }, 'error no controlat');   // log complet; al client, res d'intern
    respondreProblema(res, req, { status: 500, codi: 'ERROR_INTERN', detail: `Error intern. Referència: ${req.id}` });
  };
}
module.exports = { middlewareErrors };

Com que respondreProblema (03-01) omple type, title, status, detail, codi i instance, i el middlewareRequestId ja ha posat X-Request-Id a la resposta, un GET /v1/productes/p-999 retorna exactament el que el contracte promet: 404, Content-Type: application/problem+json i el cos {"type": "https://techcorp.example/errors/producte-no-existeix", "title": "Error en la petició", "status": 404, "detail": "No existeix el producte p-999", "codi": "PRODUCTE_NO_EXISTEIX", "instance": "/v1/productes/p-999"}.

  1. Composició de l'aplicació i arrencada amb aturada ordenada

app.js munta les peces en l'ordre correcte (l'ordre dels middlewares a Express importa: request-id i logging abans de les rutes; el d'errors, l'últim):

// src/app.js
const express = require('express');
const pinoHttp = require('pino-http');
const { middlewareRequestId, middlewareErrors, crearRutesSalut, respondreProblema } = require('@techcorp/comu-http');
const { crearRutesProductes } = require('./rutes/productes');
const { crearProductesServei } = require('./serveis/productesServei');

// Rep les dependències ja construïdes: així les proves poden passar un repositori en memòria
function crearApp({ repositori, logger, comprovacionsSalut = {} }) {
  const app = express();
  app.disable('x-powered-by');                                   // no anunciem la tecnologia
  app.use(middlewareRequestId());                                // 1. X-Request-Id a req.id i a la resposta
  app.use(pinoHttp({ logger, genReqId: (req) => req.id }));      // 2. una línia de log per petició, amb el mateix id
  app.use(express.json({ limit: '100kb' }));                     // 3. cos JSON (Catàleg encara no el fa servir; la plantilla el porta)
  app.use(crearRutesSalut({ comprovacions: comprovacionsSalut }));   // 4. /health/live i /health/ready (03-05)

  const productesServei = crearProductesServei({ repositori });
  app.use(crearRutesProductes({ productesServei }));             // 5. l'API

  app.use((req, res) => respondreProblema(res, req, { status: 404, codi: 'RUTA_NO_EXISTEIX', detail: `No existeix ${req.method} ${req.originalUrl}` }));
  app.use(middlewareErrors({ logger }));                         // 6. SEMPRE l'últim
  return app;
}

module.exports = { crearApp };

I servidor.js, l'únic fitxer amb efectes sobre el món (port, base de dades, senyals):

// src/servidor.js
const { crearLogger } = require('@techcorp/comu-http');
const { config } = require('./config');                 // { PORT, MONGO_URL, MONGO_BD, LOG_NIVELL } validats; 04-03 ho amplia
const { connectarMongo } = require('./infra/mongo');
const { crearProductesRepositori } = require('./repositoris/productesRepositori');
const { crearApp } = require('./app');

async function arrencar() {
  const logger = crearLogger({ servei: 'servei-cataleg', nivell: config.LOG_NIVELL });

  // 1. Dependències externes primer: si Mongo no hi és, el procés mor i Kubernetes ho reintentarà
  const mongo = await connectarMongo({ url: config.MONGO_URL, nomBd: config.MONGO_BD, logger });
  const repositori = crearProductesRepositori(mongo.bd);
  await repositori.assegurarIndexs();

  // 2. Compondre l'app amb les seves dependències reals
  const app = crearApp({ repositori, logger, comprovacionsSalut: { mongodb: mongo.ping } });

  // 3. Escoltar
  const servidor = app.listen(config.PORT, () => logger.info({ port: config.PORT }, 'servei-cataleg escoltant'));
  servidor.keepAliveTimeout = 65000;                    // > timeout del balancejador (60 s típic): evita talls en keep-alive

  // 4. Aturada ordenada. Kubernetes envia SIGTERM i espera (30 s per defecte) abans del SIGKILL.
  //    crearRutesSalut ja ha posat /health/ready a 503 en rebre SIGTERM (03-05): deixen d'arribar peticions noves.
  const aturar = (senyal) => {
    logger.info({ senyal }, 'aturada iniciada');
    servidor.close(async () => {                        // deixa d'acceptar connexions; espera les peticions en curs
      await mongo.tancar();
      logger.info('aturada completada');
      process.exit(0);
    });
    setTimeout(() => { logger.error('aturada forçada per timeout'); process.exit(1); }, 10000).unref();
  };
  process.on('SIGTERM', () => aturar('SIGTERM'));
  process.on('SIGINT', () => aturar('SIGINT'));         // Ctrl+C en local
}

arrencar().catch((err) => { console.error('no s\'ha pogut arrencar', err); process.exit(1); });

La seqüència d'aturada (SIGTERM → /health/ready a 503 → el balancejador deixa d'enviar peticions → servidor.close() acaba les peticions en curs → tancament de Mongo → exit(0), amb un límit de seguretat de 10 s) és la que evita perdre peticions a cada desplegament; a 05-04 veurem com encaixa amb els rolling updates de Kubernetes.

src/config.js és, de moment, mínim: llegeix PORT (per defecte 3001), MONGO_URL (per defecte mongodb://localhost:27017), MONGO_BD (cataleg) i LOG_NIVELL (info) de process.env i falla si MONGO_URL no és una URL vàlida. La versió completa, amb zod i tots els tipus de configuració, és el tema de 04-03.

  1. Executar en local: MongoDB, llavor i proves amb curl

MongoDB en un contenidor (una línia; Docker en profunditat és 05-01) i el .env local, copiat d'.env.exemple (que sí que és a git; .env no):

docker run -d --name mongo-cataleg -p 27017:27017 mongo:7
cp .env.exemple .env     # PORT=3001  MONGO_URL=mongodb://localhost:27017  MONGO_BD=cataleg  LOG_NIVELL=debug

Llavor de dades amb els productes de tot el curs. És idempotent (upsert), així es pot executar tants cops com es vulgui:

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

const PRODUCTES = [
  { _id: 'p-501', producteId: 'p-501', nom: 'Auriculars BT X200', categoria: 'audio', preu: 59.90, publicat: true,
    descripcio: 'Auriculars sense fil amb cancel·lació de soroll', atributs: { color: 'negre', autonomiaHores: 30, bluetooth: '5.3' } },
  { _id: 'p-777', producteId: 'p-777', nom: 'Cable USB-C 2 m', categoria: 'accessoris', preu: 9.90, publicat: true,
    descripcio: 'Cable USB-C a USB-C, 100 W', atributs: { longitudMetres: 2, potenciaW: 100 } },
  { _id: 'p-802', producteId: 'p-802', nom: 'Altaveu portàtil S10', categoria: 'audio', preu: 34.50, publicat: false, atributs: { color: 'blau' } }
];

async function main() {
  const client = new MongoClient(process.env.MONGO_URL ?? 'mongodb://localhost:27017');
  await client.connect();
  const colleccio = client.db(process.env.MONGO_BD ?? 'cataleg').collection('productes');
  for (const p of PRODUCTES) {
    await colleccio.updateOne({ _id: p._id }, { $set: { ...p, actualitzatEn: new Date().toISOString() } }, { upsert: true });
  }
  console.log(`llavor aplicada: ${PRODUCTES.length} productes`);
  await client.close();
}
main().catch((e) => { console.error(e); process.exit(1); });
npm run llavor      # llavor aplicada: 3 productes
npm run dev         # {"level":30,"servei":"servei-cataleg","port":3001,"msg":"servei-cataleg escoltant"}

Proves manuals i el que ha de sortir:

# 1. Lot (el contracte que consumirà Comandes a 04-04). p-999 no existeix → va a noTrobats, no és 404
curl -s -i "http://localhost:3001/v1/productes?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

{"dades":[{"id":"p-501","nom":"Auriculars BT X200","preu":59.9,"moneda":"EUR","disponible":true},
          {"id":"p-777","nom":"Cable USB-C 2 m","preu":9.9,"moneda":"EUR","disponible":true}],
 "noTrobats":["p-999"]}
# 2. Detall inexistent → 404 application/problem+json, codi PRODUCTE_NO_EXISTEIX (el JSON de l'apartat 6)
curl -s -i http://localhost:3001/v1/productes/p-999 | head -2
# 3. Llistat paginat: 1 per pàgina, categoria audio (p-501 i p-802; p-802 surt amb disponible=false)
curl -s "http://localhost:3001/v1/productes?categoria=audio&limit=1"
# {"dades":[{"id":"p-501",...}],"paginacio":{"limit":1,"hiHaSeguent":true,"seguentCursor":"eyJpZCI6InAtNTAxIn0"},"_links":{...,"seguent":{"href":"/v1/productes?categoria=audio&limit=1&cursor=eyJpZCI6InAtNTAxIn0"}}}
# 4. Validació: limit=500 (o 101 ids) → 400 PETICIO_INVALIDA
curl -s "http://localhost:3001/v1/productes?limit=500" | jq .codi     # "PETICIO_INVALIDA"
# 5. Salut: llest amb Mongo; 503 sense Mongo
curl -s http://localhost:3001/health/ready      # {"estat":"llest","dependencies":{"mongodb":"ok"}}
docker stop mongo-cataleg && curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3001/health/ready   # 503
docker start mongo-cataleg
# 6. Aturada ordenada: Ctrl+C a la terminal de npm run dev → "aturada iniciada" ... "aturada completada"

Si els sis passos responen així, servei-cataleg compleix el seu contracte i és a punt perquè Comandes el consumeixi. Un fitxer peticions.http amb aquestes mateixes crides, per a l'extensió REST Client de VS Code, acompanya el repositori.

Errors Comuns i Consells

  • app.listen dins d'app.js. Trenca la testabilitat i fa que cada require obri un port. crearApp() retorna, servidor.js escolta.
  • Rutes que parlen amb MongoDB directament. Funciona fins que cal provar sense base de dades o canviar el driver. Ruta → servei → repositori, sempre.
  • Formatar errors a cada ruta. S'acaba amb cinc formats diferents. next(err) i un únic middlewareErrors.
  • Oblidar el try/catch en un gestor async. Express 4 no captura promeses rebutjades: la petició es queda penjada. Embolcalla sempre (o fes servir express-async-errors, o Express 5, que sí que ho fa).
  • Retornar el document de MongoDB tal qual. Exposa camps interns i converteix el teu esquema de BD en contracte públic. Sempre a través d'aDto.
  • Ignorar SIGTERM. Node per defecte mor en sec: les peticions en curs reben reset. servidor.close() + tancament de connexions + timeout de seguretat.
  • Sense timeout de selecció de servidor a Mongo (arrencada amb Mongo caigut: 30 s en silenci) i llavors no idempotents (insertMany falla la segona vegada). serverSelectionTimeoutMS i upsert.

Exercicis

Exercici 1. L'equip d'Experiència de compra demana que el lot GET /v1/productes?ids= no retorni els productes amb publicat: false a dades, sinó que els inclogui a noTrobats (per a Comandes, "no publicat" i "no existeix" volen dir el mateix: no es pot vendre). Modifica el servei (no la ruta ni el repositori) per aconseguir-ho i explica per què la capa correcta és aquesta.

Exercici 2. Escriu una versió en memòria del repositori (crearProductesRepositoriEnMemoria(productes)) que implementi cercarPerIds, obtenirPerId, llistar i assegurarIndexs sobre un array. Després, mostra com es cridaria crearApp amb ell i amb un logger silenciós (pino({ level: 'silent' })). És la base que 04-05 farà servir.

Exercici 3. Un company proposa que /health/ready de Catàleg comprovi també que el gateway (port 8080) respon, "per assegurar-nos que el sistema funciona". Explica, amb la taula de 03-05, per què és una mala idea i què passaria amb les rèpliques de Catàleg si el gateway caigués.

Solucions

Solució 1.

async obtenirLot(ids) {
  const docs = await repositori.cercarPerIds(ids);
  const vendibles = docs.filter((d) => d.publicat === true);
  const trobats = new Set(vendibles.map((d) => d._id));
  return { dades: vendibles.map(aDto), noTrobats: ids.filter((id) => !trobats.has(id)) };
}

És una regla de negoci del contracte ("al lot només es retornen productes vendibles"), no una qüestió d'HTTP (ruta) ni d'emmagatzematge (repositori): el repositori ha de continuar podent retornar no publicats per al detall o el panell d'administració. Convé, a més, actualitzar contractes/openapi.yaml a la mateixa PR (03-06) i avisar Comandes, que interpretarà aquests ids com a PRODUCTE_NO_DISPONIBLE.

Solució 2.

// proves/dobles/productesRepositoriEnMemoria.js
function crearProductesRepositoriEnMemoria(productes = []) {
  const dades = [...productes];
  return {
    async cercarPerIds(ids) { return dades.filter((p) => ids.includes(p._id)); },
    async obtenirPerId(id) { return dades.find((p) => p._id === id) ?? null; },
    async llistar({ categoria, despresDeId, limit }) {
      return dades.filter((p) => !categoria || p.categoria === categoria).filter((p) => !despresDeId || p._id > despresDeId)
                  .sort((a, b) => a._id.localeCompare(b._id)).slice(0, limit + 1);
    },
    async assegurarIndexs() {}
  };
}
// ús: cap canvi a app.js, rutes ni servei; aquesta és la recompensa de la injecció de dependències
const app = crearApp({ repositori: crearProductesRepositoriEnMemoria([{ _id: 'p-501', nom: 'Auriculars BT X200', preu: 59.90, publicat: true, categoria: 'audio' }]),
                       logger: require('pino')({ level: 'silent' }) });

Solució 3. /health/ready ha de comprovar les dependències pròpies del servei, aquelles sense les quals aquesta rèplica no pot atendre una petició: MongoDB. El gateway no és una dependència de Catàleg (és al revés). Si s'hi inclogués i el gateway caigués, totes les rèpliques de Catàleg passarien a "no llest", Kubernetes les trauria del balanceig, i quan el gateway tornés no tindria cap Catàleg on enviar trànsit fins que les rèpliques tornessin a estar ready: una caiguda parcial convertida en caiguda total i prolongada. A més, el BFF mòbil i altres consumidors interns que no passen pel gateway perdrien Catàleg sense motiu.

Conclusió

Tenim el primer microservei de TechCorp funcionant: un projecte Node.js 20 + Express amb dependències mínimes (express, mongodb, pino, zod, @techcorp/comu-http), una estructura per capes (rutes → servei → repositori → infraestructura) en què cada capa rep les seves dependències per paràmetre, la separació entre crearApp() i servidor.js que fa possible provar-lo sense ports ni base de dades, els tres endpoints GET /v1/productes (lot amb {dades, noTrobats} i Cache-Control, detall amb 404 PRODUCTE_NO_EXISTEIX, llistat paginat per cursor) fidels als contractes de 03-01 i 03-06, validació amb zod, un únic middleware que converteix qualsevol error en RFC 7807 amb X-Request-Id, salut amb crearRutesSalut, aturada ordenada davant de SIGTERM, i una llavor amb p-501 i p-777 per provar-lo amb curl.

Hi ha una peça que hem deixat deliberadament petita: src/config.js. Un servei que s'executa al portàtil, a staging i a producció, replicat i contenidoritzat, necessita una manera disciplinada de saber a quin MongoDB connectar-se, quin port escoltar i quines funcionalitats activar, sense tocar el codi i sense filtrar contrasenyes. Aquesta disciplina, i el mòdul config.js complet amb validació fail-fast que reutilitzaran tots els serveis, és la lliçó següent.

Curs de Microserveis

Mòdul 1: Introducció als Microserveis

Mòdul 2: Disseny de Microserveis

Mòdul 3: Comunicació entre Microserveis

Mòdul 4: Implementació de Microserveis

Mòdul 5: Desplegament i Orquestració

Mòdul 6: Monitoratge i Manteniment

Mòdul 7: Seguretat en Microserveis

Mòdul 8: Casos d'Estudi i Exemples Pràctics

© Copyright 2026. Tots els drets reservats