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
- Crear el projecte i triar dependències
- Estructura per capes i per què separar
appdeservidor - Infraestructura: la connexió a MongoDB
- Repositori i servei de productes
- Les rutes:
GET /v1/productesen les seves tres formes - Validació d'entrada i gestió centralitzada d'errors
- Composició de l'aplicació i arrencada amb aturada ordenada
- Executar en local: MongoDB, llavor i proves amb
curl
- 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.
- Estructura per capes i per què separar
app de servidor
app de servidorservei-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.
- 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.
- 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.
- Les rutes:
GET /v1/productes en les seves tres formes
GET /v1/productes en les seves tres formesLa 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.
- 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"}.
- 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.
- Executar en local: MongoDB, llavor i proves amb
curl
curlMongoDB 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=debugLlavor 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.listendins d'app.js. Trenca la testabilitat i fa que cadarequireobri un port.crearApp()retorna,servidor.jsescolta.- 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 únicmiddlewareErrors. - Oblidar el
try/catchen un gestorasync. Express 4 no captura promeses rebutjades: la petició es queda penjada. Embolcalla sempre (o fes servirexpress-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 (
insertManyfalla la segona vegada).serverSelectionTimeoutMSiupsert.
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
- Conceptes Bàsics de Microserveis
- Avantatges i Desavantatges dels Microserveis
- Comparació amb l'Arquitectura Monolítica
- Quan Adoptar Microserveis: Criteris de Decisió
- El Cas Pràctic del Curs: la Botiga Online de TechCorp
Mòdul 2: Disseny de Microserveis
- Principis de Disseny de Microserveis
- Descomposició d'Aplicacions Monolítiques
- Definició de Bounded Contexts
- Gestió de Dades: una Base de Dades per Servei
- Consistència Distribuïda: Sagues, CQRS i Event Sourcing
Mòdul 3: Comunicació entre Microserveis
- APIs RESTful
- Missatgeria Asíncrona
- Protocols de Comunicació: gRPC, GraphQL
- API Gateway i Backend for Frontend
- Descobriment de Serveis i Balanceig de Càrrega
- Contractes i Versionat d'APIs
Mòdul 4: Implementació de Microserveis
- Elecció de Tecnologies i Eines
- Desenvolupament d'un Microservei Simple
- Gestió de Configuració
- Integració Pràctica: Consumir APIs i Publicar Esdeveniments
- Proves en Microserveis: Unitàries, d'Integració i de Contracte
Mòdul 5: Desplegament i Orquestració
- Contenidors i Docker
- Orquestració amb Kubernetes
- CI/CD per a Microserveis
- Estratègies de Desplegament: Rolling, Blue-Green i Canary
- Service Mesh: Istio i Linkerd
Mòdul 6: Monitoratge i Manteniment
- Monitoratge i Logging
- Traçabilitat Distribuïda amb OpenTelemetry
- Gestió d'Errors i Recuperació
- Escalabilitat i Rendiment
- SLOs, Alertes i Gestió d'Incidents
Mòdul 7: Seguretat en Microserveis
- Autenticació i Autorització
- Seguretat en la Comunicació
- Pràctiques de Seguretat
- Seguretat en Contenidors i Kubernetes
