Aquesta és la lliçó en què el disseny dels mòduls 2 i 3 es converteix en un servei que funciona de cap a cap. Construïm el cor de servei-comandes (port 3002, equip del Luis) sobre la mateixa plantilla que servei-cataleg, però amb tot el que Catàleg no tenia: PostgreSQL amb transaccions, crides HTTP sortints a Catàleg i Clients amb el seu timeout i la seva ACL, el cas d'ús crearComanda que desa la comanda i el seu esdeveniment comanda.creada a la mateixa transacció (outbox), el relay que publica a RabbitMQ amb confirmacions, i els consumidors que reben els esdeveniments de la saga i mouen la comanda per la seva màquina d'estats fins a CONFIRMADA o CANCELLADA. Acabem amb una prova manual completa: crear la comanda de l'Ana amb curl, veure-la PENDENT, injectar un estoc.reservat i veure-la ESTOC_RESERVAT. Mostrem complet l'essencial i resumim el repetitiu; els fitxers omesos són variants directes del que ja vas veure a 04-02.
Contingut
- Estructura del projecte i dependències
- PostgreSQL: pool, transaccions i migracions
- Clients HTTP sortints: Catàleg (amb ACL) i Clients
- Domini: l'agregat
Comandai la màquina d'estats - Repositori amb outbox:
desarAmbEsdeveniments()iprocessarUnCop() - El cas d'ús
crearComandai la rutaPOST /v1/comandes - El relay de l'outbox
- Consumidors: la saga i la rèplica de clients
GET /v1/comandes/{id}amb ETag i l'arrencada completa- Prova de cap a cap
- Estructura del projecte i dependències
npm install express pg amqplib pino pino-http zod @techcorp/comu-http && npm install -D nodemon dotenvservei-comandes/ ├── src/ │ ├── servidor.js, app.js, config.js (04-03), salut.js │ ├── rutes/comandes.js # POST /v1/comandes, GET /v1/comandes/:id │ ├── casos-us/crearComanda.js │ ├── domini/comanda.js # agregat: crear, calcular total, aplicar esdeveniment │ ├── domini/maquinaEstatsComanda.js # TRANSICIONS, MOTIUS, transicionar (02-05, tal qual) │ ├── repositoris/comandaRepositori.js # desarAmbEsdeveniments, obtenir, claus d'idempotència, processarUnCop │ ├── clients/catalegClient.js, clients/clientsClient.js │ ├── traductors/traductorProducte.js # ACL (02-03, 03-06) │ ├── infra/postgres.js │ └── missatgeria/relayOutbox.js, consumidorSaga.js, consumidorClients.js ├── migracions/001-esquema-inicial.sql … 004-claus-idempotencia.sql ├── scripts/migrar.js, scripts/publicarEsdeveniment.js └── contractes/openapi.yaml, contractes/asyncapi.yaml
Respecte a Catàleg canvien el driver (pg en lloc de mongodb), apareix amqplib, i hi ha tres carpetes noves: casos-us/ (la lògica d'aplicació és més rica que a Catàleg), domini/ i missatgeria/. missatgeria/topologia.js i publicador.js de 03-02 vénen de @techcorp/comu-http.
- PostgreSQL: pool, transaccions i migracions
// src/infra/postgres.js
const { Pool } = require('pg');
function crearPoolPostgres({ url, logger }) {
const pool = new Pool({ connectionString: url, max: 10, connectionTimeoutMillis: 5000, idleTimeoutMillis: 30000 });
pool.on('error', (err) => logger.error({ err }, 'error en connexió inactiva del pool')); // sense això, un error tomba el procés
return {
consultar: (sql, params) => pool.query(sql, params), // fora de transacció
// Executa fn(tx) dins de BEGIN/COMMIT; ROLLBACK si fn llança. tx.consultar fa servir SEMPRE la mateixa connexió.
async transaccio(fn) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const resultat = await fn({ consultar: (sql, params) => client.query(sql, params) });
await client.query('COMMIT');
return resultat;
} catch (err) { await client.query('ROLLBACK'); throw err; }
finally { client.release(); }
},
ping: () => pool.query('SELECT 1'),
tancar: () => pool.end()
};
}
module.exports = { crearPoolPostgres };transaccio(fn) és la peça que fa possible l'outbox: tot el que s'executi amb el tx rebut va a la mateixa transacció, o es confirma sencer o no es confirma res.
Les migracions són els esquemes ja dissenyats, sense canvis de fons: 001-esquema-inicial.sql (comandes, linies_comanda, clients_ref de 02-04 §7.1), 002-outbox.sql (outbox amb el seu índex parcial de pendents, 02-05 §7), 003-esdeveniments-processats.sql (esdeveniments_processats amb clau primària composta (esdeveniment_id, consumidor)), 004-claus-idempotencia.sql (claus_idempotencia de 02-05 §8, a la qual afegim una columna empremta TEXT NOT NULL amb el hash del cos per detectar la reutilització de clau amb un altre cos, 03-01). scripts/migrar.js (~30 línies) les aplica en ordre i anota cadascuna a migracions_aplicades per no repetir-la; s'executa amb npm run migrar abans d'arrencar (a Kubernetes serà un Job o un init container, 05-02).
- Clients HTTP sortints: Catàleg (amb ACL) i Clients
El client de Catàleg fa evolucionar el de 03-01: rep la URL i el timeout per paràmetre (04-03), crida /v1/productes?ids= i aplica l'ACL traductorProducte (03-06) abans de retornar, de manera que la resta de Comandes mai no veu el JSON de Catàleg.
// src/traductors/traductorProducte.js — el lector tolerant de 03-06, sense canvis
function aProducteDeComandes(dto) {
return { producteId: dto.id, nom: dto.nom, preuUnitari: Number(dto.preu), disponible: dto.disponible !== false };
}
module.exports = { aProducteDeComandes };// src/clients/catalegClient.js
const { ErrorNegoci } = require('@techcorp/comu-http');
const { aProducteDeComandes } = require('../traductors/traductorProducte');
function crearCatalegClient({ urlBase, timeoutMs }) {
return {
// Retorna un Map producteId → { producteId, nom, preuUnitari, disponible }
async obtenirProductes(ids, { requestId }) {
const url = `${urlBase}/v1/productes?ids=${encodeURIComponent(ids.join(','))}`;
let resposta;
try {
resposta = await fetch(url, { headers: { Accept: 'application/json', 'X-Request-Id': requestId }, signal: AbortSignal.timeout(timeoutMs) });
} catch (err) { // timeout o xarxa: 503 per al nostre client (03-01)
throw new ErrorNegoci('DEPENDENCIA_NO_DISPONIBLE', `Catàleg no disponible: ${err.name}`, 503);
}
if (!resposta.ok) {
const problema = await resposta.json().catch(() => ({}));
if (resposta.status >= 500) throw new ErrorNegoci('DEPENDENCIA_NO_DISPONIBLE', `Catàleg ha respost ${resposta.status}`, 503);
throw new ErrorNegoci('PETICIO_INVALIDA', `Catàleg ha rebutjat la petició (${problema.codi ?? resposta.status})`, 400);
}
const { dades, noTrobats } = await resposta.json();
const productes = dades.map(aProducteDeComandes);
const noVendibles = [...noTrobats, ...productes.filter((p) => !p.disponible).map((p) => p.producteId)];
if (noVendibles.length > 0) throw new ErrorNegoci('PRODUCTE_NO_DISPONIBLE', `Productes no disponibles: ${noVendibles.join(', ')}`, 422);
return new Map(productes.map((p) => [p.producteId, p]));
}
};
}
module.exports = { crearCatalegClient };El client de Clients és simètric i més curt: GET {CLIENTS_URL}/v1/clients/{id} amb el mateix timeout; 404 → ErrorNegoci('CLIENT_NO_EXISTEIX', ..., 404); 5xx/xarxa → DEPENDENCIA_NO_DISPONIBLE; retorna { clientId, nom, email, adreces }. Sense reintents ni circuit breaker (06-03): només timeout.
- Domini: l'agregat
Comanda i la màquina d'estats
Comanda i la màquina d'estatsdomini/maquinaEstatsComanda.js és literalment el de 02-05 (TRANSICIONS, MOTIUS, transicionar). L'agregat afegeix la construcció i el càlcul del total amb cèntims enters per evitar 59.90 + 9.90 * 2 = 79.69999...:
// src/domini/comanda.js
const { randomUUID } = require('node:crypto');
const { transicionar, MOTIUS } = require('./maquinaEstatsComanda');
const aCentims = (n) => Math.round(n * 100);
// Construeix una Comanda PENDENT a partir de la petició validada i del que van dir Clients i Catàleg
function crearComanda({ clientId, linies, adrecaEnviament }, { client, productes }) {
const liniesCongelades = linies.map((l, i) => {
const p = productes.get(l.producteId); // catalegClient ja ha garantit que existeix i és vendible
return { linia: i + 1, producteId: p.producteId, nomProducte: p.nom, preuUnitari: p.preuUnitari, quantitat: l.quantitat };
});
const totalCentims = liniesCongelades.reduce((acc, l) => acc + aCentims(l.preuUnitari) * l.quantitat, 0);
return {
comandaId: `com-${randomUUID().slice(0, 8)}`, // id opac generat pel propietari (02-04)
clientId, client: { nom: client.nom, email: client.email },
estat: 'PENDENT', linies: liniesCongelades, adrecaEnviament,
total: totalCentims / 100, motiuCancellacio: null, creatEn: new Date().toISOString()
};
}
// Aplica un esdeveniment de la saga; retorna el nou estat o null si no hi ha transició (esdeveniment tardà/duplicat)
function aplicarEsdevenimentSaga(comanda, tipusEsdeveniment) {
const nouEstat = transicionar(comanda.estat, tipusEsdeveniment);
if (!nouEstat) return null;
comanda.estat = nouEstat;
if (nouEstat === 'CANCELLADA') comanda.motiuCancellacio = MOTIUS[tipusEsdeveniment];
return nouEstat;
}
// Càrrega que viatja a comanda.creada / comanda.confirmada / comanda.cancellada (contracte de 02-05 i AsyncAPI de 03-06)
function dadesPerAConsumidors(c) {
return { comandaId: c.comandaId, clientId: c.clientId, client: c.client, adrecaEnviament: c.adrecaEnviament, total: c.total,
linies: c.linies.map((l) => ({ producteId: l.producteId, nom: l.nomProducte, quantitat: l.quantitat, preuUnitari: l.preuUnitari })) };
}
module.exports = { crearComanda, aplicarEsdevenimentSaga, dadesPerAConsumidors };
- Repositori amb outbox:
desarAmbEsdeveniments() i processarUnCop()
desarAmbEsdeveniments() i processarUnCop()// src/repositoris/comandaRepositori.js
const { randomUUID } = require('node:crypto');
function crearComandaRepositori(bd) {
// Desa comanda + línies + rèplica del client + esdeveniments a l'outbox, en UNA transacció (02-05 §7).
// `tx` opcional: si qui crida ja és dins d'una transacció (processarUnCop), reutilitzem la seva.
async function desarAmbEsdeveniments(comanda, esdeveniments, tx) {
const feina = async (t) => {
await t.consultar(`INSERT INTO comandes (comanda_id, client_id, estat, total, adreca_enviament, motiu_cancellacio, creat_en, actualitzat_en)
VALUES ($1,$2,$3,$4,$5,$6,$7,NOW())
ON CONFLICT (comanda_id) DO UPDATE SET estat = EXCLUDED.estat, motiu_cancellacio = EXCLUDED.motiu_cancellacio, actualitzat_en = NOW()`,
[comanda.comandaId, comanda.clientId, comanda.estat, comanda.total, comanda.adrecaEnviament, comanda.motiuCancellacio, comanda.creatEn]);
for (const l of comanda.linies) { // les línies mai no canvien després de la creació: insert idempotent
await t.consultar(`INSERT INTO linies_comanda (comanda_id, linia, producte_id, nom_producte, preu_unitari, quantitat)
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`, [comanda.comandaId, l.linia, l.producteId, l.nomProducte, l.preuUnitari, l.quantitat]);
}
if (comanda.client) { // rèplica clients_ref (02-04): l'escalfem amb el que ja sabem
await t.consultar(`INSERT INTO clients_ref (client_id, nom, email, actualitzat_en) VALUES ($1,$2,$3,NOW())
ON CONFLICT (client_id) DO NOTHING`, [comanda.clientId, comanda.client.nom, comanda.client.email]);
}
for (const ev of esdeveniments) { // l'outbox: mateixos INSERT, mateixa transacció
await t.consultar(`INSERT INTO outbox (esdeveniment_id, agregat_tipus, agregat_id, tipus, versio, carrega) VALUES ($1,'Comanda',$2,$3,$4,$5)`,
[`evt-${randomUUID()}`, comanda.comandaId, ev.tipus, ev.versio ?? 1, ev.carrega]);
}
};
return tx ? feina(tx) : bd.transaccio(feina);
}
async function obtenir(comandaId, tx = bd) {
const { rows } = await tx.consultar(`SELECT c.*, r.nom AS client_nom, r.email AS client_email
FROM comandes c LEFT JOIN clients_ref r ON r.client_id = c.client_id WHERE c.comanda_id = $1`, [comandaId]);
if (rows.length === 0) return null;
const linies = (await tx.consultar('SELECT * FROM linies_comanda WHERE comanda_id = $1 ORDER BY linia', [comandaId])).rows;
return aComanda(rows[0], linies); // fila → agregat (noms camelCase; ~10 línies, omeses)
}
// Idempotència de l'API (02-05 §8c, 03-01): clau → resposta desada
const cercarClau = async (clau) => (await bd.consultar('SELECT empremta, resposta FROM claus_idempotencia WHERE clau = $1', [clau])).rows[0] ?? null;
const desarClau = (t, clau, comandaId, empremta, resposta) =>
t.consultar('INSERT INTO claus_idempotencia (clau, comanda_id, empremta, resposta) VALUES ($1,$2,$3,$4)', [clau, comandaId, empremta, resposta]);
// Idempotència de consumidors (02-05 §8b, 03-02): efecte + registre a la MATEIXA transacció
async function processarUnCop(esdevenimentId, consumidor, fn) {
return bd.transaccio(async (tx) => {
const { rowCount } = await tx.consultar('INSERT INTO esdeveniments_processats (esdeveniment_id, consumidor) VALUES ($1,$2) ON CONFLICT DO NOTHING', [esdevenimentId, consumidor]);
if (rowCount === 0) return 'DUPLICAT'; // ja processat: la transacció no fa res més
await fn(tx); // l'efecte real, amb el mateix tx
return 'PROCESSAT';
});
}
return { desarAmbEsdeveniments, obtenir, cercarClau, desarClau, processarUnCop, transaccio: bd.transaccio };
}
module.exports = { crearComandaRepositori };Dos matisos: processarUnCop insereix primer a esdeveniments_processats (si dues rèpliques reben el mateix esdeveniment alhora, la segona es bloqueja a la fila i, quan la primera fa COMMIT, veu rowCount = 0), i l'efecte s'executa amb el mateix tx, de manera que desarAmbEsdeveniments(comanda, esdeveniments, tx) va en aquesta transacció: el canvi d'estat, l'esdeveniment de sortida i la marca de processat es confirmen junts o no se'n confirma cap.
- El cas d'ús
crearComanda i la ruta POST /v1/comandes
crearComanda i la ruta POST /v1/comandes// src/casos-us/crearComanda.js
const { createHash } = require('node:crypto');
const { ErrorNegoci } = require('@techcorp/comu-http');
const Comanda = require('../domini/comanda');
const empremtaDe = (cos) => createHash('sha256').update(JSON.stringify(cos)).digest('hex');
function crearCasUsCrearComanda({ repositori, catalegClient, clientsClient, logger }) {
return async function crearComanda(peticio, { clauIdempotencia, requestId }) {
// 1. Idempotència: mateixa clau + mateix cos → mateixa resposta; mateixa clau + un altre cos → 422 (03-01)
const empremta = empremtaDe(peticio);
const previa = await repositori.cercarClau(clauIdempotencia);
if (previa) {
if (previa.empremta !== empremta) throw new ErrorNegoci('CLAU_IDEMPOTENCIA_REUTILITZADA', 'La Idempotency-Key ja s\'ha fet servir amb un altre cos', 422);
return { comanda: previa.resposta, repetida: true };
}
// 2. Client i productes EN PARAL·LEL: són independents i així la latència és la del més lent, no la suma
const [client, productes] = await Promise.all([
clientsClient.obtenirClient(peticio.clientId, { requestId }), // CLIENT_NO_EXISTEIX → 404
catalegClient.obtenirProductes([...new Set(peticio.linies.map((l) => l.producteId))], { requestId }) // PRODUCTE_NO_DISPONIBLE → 422
]);
// 3. L'agregat, en PENDENT, amb noms i preus congelats i el total calculat
const comanda = Comanda.crearComanda(peticio, { client, productes });
const resposta = aRepresentacio(comanda); // el JSON de 03-01 (id, estat, linies, total, _links); ~8 línies, omeses
// 4. Comanda + comanda.creada + clau d'idempotència: UNA transacció. Ni RabbitMQ ni HTTP aquí dins.
await repositori.transaccio(async (tx) => {
await repositori.desarAmbEsdeveniments(comanda, [{ tipus: 'comanda.creada', versio: 1, carrega: Comanda.dadesPerAConsumidors(comanda) }], tx);
await repositori.desarClau(tx, clauIdempotencia, comanda.comandaId, empremta, resposta);
});
logger.info({ comandaId: comanda.comandaId, clientId: comanda.clientId, total: comanda.total, requestId }, 'comanda creada');
return { comanda: resposta, repetida: false };
};
}
module.exports = { crearCasUsCrearComanda };La ruta és la de 03-01 amb /v1/ i sense el try/catch de mapatge d'errors (ara ho fa middlewareErrors de 04-02, perquè els clients llancen ErrorNegoci amb status):
// src/rutes/comandes.js (fragment POST)
encaminador.post('/v1/comandes', async (req, res, next) => {
try {
const clauIdempotencia = req.get('Idempotency-Key');
if (!clauIdempotencia) throw new ErrorNegoci('PETICIO_INVALIDA', 'Falta la capçalera Idempotency-Key', 400);
const peticio = esquemaNovaComanda.parse(req.body); // zod: clientId, linies[{producteId, quantitat ≥ 1}], adrecaEnviament (400/422 via middleware)
const { comanda } = await crearComanda(peticio, { clauIdempotencia, requestId: req.id });
res.status(202).location(`/v1/comandes/${comanda.id}`).json(comanda);
} catch (err) { next(err); }
});En respondre 202, l'esdeveniment comanda.creada és a la taula outbox, no a RabbitMQ. Això és deliberat i és el que fa robust el disseny: si RabbitMQ està caigut, la comanda s'accepta igualment i l'esdeveniment sortirà quan torni.
- El relay de l'outbox
// src/missatgeria/relayOutbox.js
const { construirSobre, publicarEsdeveniment } = require('@techcorp/comu-http/missatgeria/publicador'); // 03-02
function crearRelayOutbox({ bd, canalConfirm, intervalMs, lot = 50, logger }) {
let temporitzador = null, aturat = false;
async function publicarPendents() {
// Una transacció per lot. FOR UPDATE SKIP LOCKED: si hi ha dues rèpliques de Comandes, cadascuna agafa files diferents.
const publicats = await bd.transaccio(async (tx) => {
const { rows } = await tx.consultar(
`SELECT esdeveniment_id, tipus, versio, carrega FROM outbox WHERE publicat_en IS NULL ORDER BY creat_en LIMIT $1 FOR UPDATE SKIP LOCKED`, [lot]);
if (rows.length === 0) return 0;
for (const fila of rows) {
// El sobre porta l'esdevenimentId de l'outbox (no un de nou): si el relay reintenta, el consumidor el reconeix com a duplicat
const sobre = { ...construirSobre(fila.tipus, fila.carrega, { versio: fila.versio }), esdevenimentId: fila.esdeveniment_id };
publicarEsdeveniment(canalConfirm, sobre);
}
await canalConfirm.waitForConfirms(); // el broker confirma que ha rebut (i persistit) el lot
await tx.consultar(`UPDATE outbox SET publicat_en = NOW() WHERE esdeveniment_id = ANY($1)`, [rows.map((r) => r.esdeveniment_id)]);
return rows.length; // COMMIT: només ara queden marcades
});
if (publicats > 0) logger.debug({ publicats }, 'outbox publicat');
return publicats;
}
async function cicle() {
if (aturat) return;
try {
const n = await publicarPendents();
temporitzador = setTimeout(cicle, n === lot ? 0 : intervalMs); // si el lot venia ple, continuar sense esperar
} catch (err) {
logger.error({ err }, 'relay outbox: fallada, reintent al cicle següent'); // RabbitMQ caigut: les files continuen pendents
temporitzador = setTimeout(cicle, intervalMs);
}
}
return { iniciar: () => cicle(), aturar: () => { aturat = true; clearTimeout(temporitzador); } };
}
module.exports = { crearRelayOutbox };Si el procés mor entre waitForConfirms i el COMMIT, les files es tornen a publicar al cicle següent: és l'at-least-once que vam acceptar a 03-02, absorbit per processarUnCop als consumidors.
- Consumidors: la saga i la rèplica de clients
El consumidor de la saga segueix el patró de 03-02 (prefetch, ack després de processar, nack a DLQ) sobre la cua comandes.saga; el que canvia és el gestor, que ara fa servir el domini i el repositori reals:
// src/missatgeria/consumidorSaga.js
const { declararCuaConsumidor } = require('@techcorp/comu-http/missatgeria/topologia'); // 03-02
const Comanda = require('../domini/comanda');
const CUA = 'comandes.saga';
const ESDEVENIMENTS = ['estoc.reservat', 'estoc.rebutjat', 'pagament.confirmat', 'pagament.rebutjat'];
function crearConsumidorSaga({ canal, repositori, logger }) {
// Efecte d'un esdeveniment de la saga. S'executa DINS de processarUnCop, amb el seu tx.
async function gestionar(sobre, tx) {
const comanda = await repositori.obtenir(sobre.carrega.comandaId, tx);
if (!comanda) { logger.warn({ sobre }, 'esdeveniment per a comanda desconeguda'); return; } // no reintentar: a ack (queda registrat com a processat)
let nouEstat = Comanda.aplicarEsdevenimentSaga(comanda, sobre.tipus);
if (!nouEstat) { logger.info({ comandaId: comanda.comandaId, de: comanda.estat, esdeveniment: sobre.tipus }, 'transicio_ignorada'); return; }
if (nouEstat === 'PAGADA') nouEstat = Comanda.aplicarEsdevenimentSaga(comanda, 'confirmar'); // T4 de 02-05: avui és immediata
const esdeveniments = [];
if (nouEstat === 'CONFIRMADA') esdeveniments.push({ tipus: 'comanda.confirmada', carrega: Comanda.dadesPerAConsumidors(comanda) });
if (nouEstat === 'CANCELLADA') esdeveniments.push({ tipus: 'comanda.cancellada', carrega: { ...Comanda.dadesPerAConsumidors(comanda), motiu: comanda.motiuCancellacio } });
await repositori.desarAmbEsdeveniments(comanda, esdeveniments, tx); // estat nou + esdeveniments de sortida, mateixa transacció que esdeveniments_processats
logger.info({ comandaId: comanda.comandaId, estat: nouEstat, esdeveniment: sobre.tipus }, 'comanda actualitzada');
}
async function iniciar() {
await declararCuaConsumidor(canal, CUA, ESDEVENIMENTS);
await canal.prefetch(10);
await canal.consume(CUA, async (msg) => {
if (!msg) return;
let sobre;
try { sobre = JSON.parse(msg.content.toString()); } catch { return canal.nack(msg, false, false); } // il·legible → DLQ
try {
await repositori.processarUnCop(sobre.esdevenimentId, 'comandes.saga', (tx) => gestionar(sobre, tx));
canal.ack(msg);
} catch (err) {
logger.error({ err, esdevenimentId: sobre.esdevenimentId }, 'error processant esdeveniment de saga');
canal.nack(msg, false, !msg.fields.redelivered); // 1a vegada: reencuar; 2a: DLQ (03-02)
}
});
}
return { iniciar };
}
module.exports = { crearConsumidorSaga };El consumidor de comandes.clients (consumidorClients.js) és la mateixa estructura amb un sol esdeveniment, client.actualitzat, i un gestor d'una sentència: INSERT INTO clients_ref ... ON CONFLICT (client_id) DO UPDATE SET nom, email, actualitzat_en = EXCLUDED.actualitzat_en WHERE clients_ref.actualitzat_en < EXCLUDED.actualitzat_en (la condició descarta esdeveniments que arribin desordenats). Tots dos consumidors fan servir un canal propi sobre la mateixa connexió AMQP; el relay fa servir un tercer canal de confirmació (createConfirmChannel).
GET /v1/comandes/{id} amb ETag i l'arrencada completa
GET /v1/comandes/{id} amb ETag i l'arrencada completa// src/rutes/comandes.js (fragment GET)
encaminador.get('/v1/comandes/:id', async (req, res, next) => {
try {
const comanda = await repositori.obtenir(req.params.id);
if (!comanda) throw new ErrorNegoci('COMANDA_NO_EXISTEIX', `No existeix ${req.params.id}`, 404);
const etag = `"${comanda.comandaId}:${new Date(comanda.actualitzatEn).getTime()}"`; // canvia amb cada transició
if (req.get('If-None-Match') === etag) return res.status(304).end(); // polling barat (03-01)
res.set('ETag', etag).set('Cache-Control', 'no-cache').json(aRepresentacio(comanda));
} catch (err) { next(err); }
});servidor.js amplia el de 04-02: carrega config (04-03), crea el pool i el repositori, connecta a RabbitMQ amb connectar(config.RABBITMQ_URL) (03-02) i obre canalConfirm = await connexio.createConfirmChannel(), construeix els clients HTTP, compon crearApp({ repositori, catalegClient, clientsClient, logger, comprovacionsSalut: { postgres: bd.ping, rabbitmq: () => canal.closed ? Promise.reject(new Error('canal tancat')) : Promise.resolve() } }), i arrenca relay i consumidors després de listen (crearApp construeix internament el cas d'ús amb crearCasUsCrearComanda({ repositori, catalegClient, clientsClient, logger }), de manera que les proves de 04-05 puguin substituir qualsevol de les tres peces). A l'aturada, l'ordre és l'invers: relay.aturar(), tancar canals i connexió AMQP (els missatges sense ack es reentreguen), servidor.close(), bd.tancar(). Com va decidir l'exercici 1 de 03-05, /health/ready comprova PostgreSQL i RabbitMQ, no Catàleg ni Clients.
sequenceDiagram
participant W as Web
participant R as rutes/comandes.js
participant CU as crearComanda
participant CL as servei-clients:3004
participant CA as servei-cataleg:3001
participant PG as PostgreSQL (comandes)
participant RL as relayOutbox
participant MQ as RabbitMQ techcorp.esdeveniments
participant CS as consumidorSaga
W->>R: POST /v1/comandes (Idempotency-Key)
R->>CU: crearComanda(peticio)
par en paral·lel
CU->>CL: GET /v1/clients/c-1024
CU->>CA: GET /v1/productes?ids=p-501,p-777
end
CU->>PG: BEGIN; comandes+linies+clients_ref+outbox(comanda.creada)+claus_idempotencia; COMMIT
R-->>W: 202 Location: /v1/comandes/com-…
RL->>PG: SELECT … FOR UPDATE SKIP LOCKED
RL->>MQ: publish comanda.creada (confirm)
RL->>PG: UPDATE outbox SET publicat_en
Note over MQ: Inventari reserva i publica estoc.reservat
MQ->>CS: estoc.reservat (cua comandes.saga)
CS->>PG: processarUnCop: esdeveniments_processats + estat ESTOC_RESERVAT
CS->>MQ: ack
- Prova de cap a cap
Dependències locals (04-01) i arrencada; Catàleg (04-02) ha d'estar corrent al 3001. Com que servei-clients encara no existeix, en desenvolupament s'apunta CLIENTS_URL a un stub de 20 línies (scripts/stubClients.js, Express que respon GET /v1/clients/c-1024 amb l'Ana Ruiz i 404 per a la resta):
docker run -d --name pg-comandes -p 5432:5432 -e POSTGRES_USER=svc_comandes -e POSTGRES_PASSWORD=dev-comandes -e POSTGRES_DB=comandes postgres:16
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management
cp .env.exemple .env && npm run migrar && node scripts/stubClients.js & # stub al 3004
npm run dev# 1. Crear la comanda de l'Ana → 202
curl -s -i -X POST http://localhost:3002/v1/comandes -H 'Content-Type: application/json' -H 'Idempotency-Key: 7f3c9a2e-1b4d-4e8f-9c21-5a6b7c8d9e0f' \
-d '{"clientId":"c-1024","linies":[{"producteId":"p-501","quantitat":1},{"producteId":"p-777","quantitat":2}],
"adrecaEnviament":{"carrer":"Gran Vía 12","codiPostal":"28013","ciutat":"Madrid","pais":"ES"}}'
# HTTP/1.1 202 Accepted Location: /v1/comandes/com-3f9a1c2b cos: {"id":"com-3f9a1c2b","estat":"PENDENT","total":79.7,...}
# 2. Repetir la mateixa petició → 202 amb el MATEIX id (idempotència); canviar la quantitat amb la mateixa clau → 422 CLAU_IDEMPOTENCIA_REUTILITZADA
# 3. Consultar → PENDENT (i a RabbitMQ Management, cua inventari.comandes: 1 missatge comanda.creada si Inventari no està arrencat)
curl -s http://localhost:3002/v1/comandes/com-3f9a1c2b | jq .estat # "PENDENT"
# 4. Simular Inventari: publicar estoc.reservat amb l'script
node scripts/publicarEsdeveniment.js estoc.reservat '{"comandaId":"com-3f9a1c2b","reservaId":"res-4471"}'
curl -s http://localhost:3002/v1/comandes/com-3f9a1c2b | jq .estat # "ESTOC_RESERVAT"
# 5. Simular Pagaments → CONFIRMADA, i a la cua notificacions.comandes apareix comanda.confirmada
node scripts/publicarEsdeveniment.js pagament.confirmat '{"comandaId":"com-3f9a1c2b","pagamentId":"pag-9001","import":79.70}'
curl -s http://localhost:3002/v1/comandes/com-3f9a1c2b | jq .estat # "CONFIRMADA"
# 6. Repetir el pas 4 → l'estat NO canvia (transicio_ignorada al log): idempotència + màquina d'estats// scripts/publicarEsdeveniment.js — publica un sobre estàndard a techcorp.esdeveniments: node scripts/publicarEsdeveniment.js <tipus> '<càrrega JSON>'
const { connectar } = require('@techcorp/comu-http/missatgeria/topologia');
const { construirSobre, publicarEsdeveniment } = require('@techcorp/comu-http/missatgeria/publicador');
(async () => {
const [tipus, carregaJson] = process.argv.slice(2);
const { connexio, canal } = await connectar(process.env.RABBITMQ_URL ?? 'amqp://localhost:5672');
const sobre = construirSobre(tipus, JSON.parse(carregaJson));
publicarEsdeveniment(canal, sobre);
console.log('publicat', sobre.esdevenimentId, tipus);
await canal.close(); await connexio.close();
})();Si els sis passos es comporten així, el flux "un client fa una comanda" funciona de cap a cap a la part de Comandes, amb les garanties dissenyades: sense dual write, sense comandes dobles i sense transicions impossibles.
Errors Comuns i Consells
- Publicar a RabbitMQ dins de
crearComanda. És el dual write de 02-05: comanda desada i esdeveniment perdut (o al revés). Nomésoutbox; el relay publica. - Un
esdevenimentIdnou a cada intent del relay. Els consumidors no reconeixerien el duplicat. El sobre reutilitzaoutbox.esdeveniment_id. ackabans de processar o efecte fora de la transacció deprocessarUnCop. Es perden esdeveniments o s'apliquen dues vegades. Efecte i registre amb el mateixtx;ackal final.- Consultar Clients i Catàleg en sèrie.
Promise.all: són independents. (I si un falla,Promise.allrebutja tan bon punt falla el primer: correcte aquí, perquè sense tots dos no hi ha comanda). - Calcular el total amb decimals flotants.
59.90 + 19.80no sempre és79.70. Cèntims enters i divisió al final; a la BD,NUMERIC(10,2). - Oblidar
pool.on('error'). Un error en una connexió inactiva és un'error'sense gestor: el procés mor. FOR UPDATEsenseSKIP LOCKED. Amb dues rèpliques, la segona espera la primera a cada cicle; ambSKIP LOCKEDtreballen en paral·lel sense duplicar.- Bloquejar la resposta HTTP fins que la saga acaba. El contracte és
202+ polling amb ETag. Esperar dins de la petició recrea l'acoblament temporal.
Exercicis
Exercici 1. Dues rèpliques de Comandes reben alhora el mateix POST /v1/comandes amb la mateixa Idempotency-Key (el client va reintentar per un timeout de xarxa). Recorre el codi de crearComanda i explica què passa a cada rèplica; identifica el punt en què la clau primària de claus_idempotencia decideix el resultat i què hauria de retornar la rèplica "perdedora" (pista: codi d'error de PostgreSQL 23505).
Exercici 2. Escriu el gestor del consumidor comandes.clients complet (gestionar(sobre, tx)) per a client.actualitzat amb càrrega { clientId, nom, email, actualitzatEn }, amb la protecció contra el desordre de l'apartat 8, i explica per què no cal la màquina d'estats aquí.
Exercici 3. El relay té una rèplica i publica 50 esdeveniments per cicle cada 500 ms. En el pic de campanya (×20 → 60.000 comandes/dia ≈ 0,7 comandes/s de mitjana, amb ràfegues de 10/s) és suficient? Calcula-ho i proposa dos ajustos de configuració (04-03) sense canviar codi.
Solucions
Solució 1. Totes dues rèpliques executen cercarClau gairebé alhora i cap no troba la clau; totes dues criden Clients i Catàleg i construeixen una comanda amb ids diferents (com-a…, com-b…); totes dues obren la seva transacció. La primera a fer COMMIT insereix la seva fila a claus_idempotencia; la segona, en executar desarClau, xoca amb la clau primària (23505 unique_violation) i la seva transacció sencera fa ROLLBACK: la seva comanda i la seva comanda.creada desapareixen, que és exactament el que volem. Falta tractar l'error: a crearComanda, capturar err.code === '23505' al voltant de la transacció, tornar a llegir amb cercarClau i retornar la resposta desada per la guanyadora (amb repetida: true). Sense aquest catch, la rèplica perdedora respondria 500 a un client que, si reintenta, ja rebrà la comanda correcta; amb ell, respon 202 amb la mateixa comanda.
Solució 2.
async function gestionar(sobre, tx) {
const { clientId, nom, email, actualitzatEn } = sobre.carrega;
await tx.consultar(
`INSERT INTO clients_ref (client_id, nom, email, actualitzat_en) VALUES ($1,$2,$3,$4)
ON CONFLICT (client_id) DO UPDATE SET nom = EXCLUDED.nom, email = EXCLUDED.email, actualitzat_en = EXCLUDED.actualitzat_en
WHERE clients_ref.actualitzat_en < EXCLUDED.actualitzat_en`,
[clientId, nom, email, actualitzatEn]);
}No hi ha màquina d'estats perquè clients_ref no és un agregat amb invariants: és una rèplica de només lectura (02-04) l'únic requisit de la qual és convergir a l'últim valor conegut. La condició WHERE actualitzat_en < EXCLUDED.actualitzat_en garanteix que un esdeveniment antic que arribi tard no trepitgi un de més nou; processarUnCop cobreix el duplicat exacte.
Solució 3. Capacitat del relay: 50 esdeveniments cada 500 ms = 100 esdeveniments/s (i més, perquè amb el lot ple encadena cicles sense esperar). Cada comanda genera 2 esdeveniments de Comandes (comanda.creada i comanda.confirmada/cancellada): la ràfega de 10 comandes/s són 20 esdeveniments/s, cinc vegades per sota. És suficient. El que sí que importa és la latència: amb 500 ms d'interval, un esdeveniment espera de mitjana 250 ms abans de sortir. Ajustos de configuració: abaixar OUTBOX_INTERVAL_MS a 250 en producció (ja a la taula de 04-03) i, si es vol marge, exposar la mida del lot com a OUTBOX_LOT (100). Afegir una segona rèplica de Comandes també duplica el relay sense canvis gràcies a SKIP LOCKED.
Conclusió
servei-comandes està construït en la seva part essencial i segueix, peça a peça, el que s'havia dissenyat abans: el pool de PostgreSQL amb transaccio(fn); les migracions amb els esquemes de 02-04 i 02-05 (més l'empremta a claus_idempotencia); els clients HTTP a GET /v1/productes?ids= i GET /v1/clients/{id} amb timeout, mapatge a DEPENDENCIA_NO_DISPONIBLE/CLIENT_NO_EXISTEIX/PRODUCTE_NO_DISPONIBLE i l'ACL traductorProducte; l'agregat Comanda amb preus congelats i total en cèntims; crearComanda amb Idempotency-Key, Promise.all i una sola transacció per a comanda, comanda.creada i clau; el relay de l'outbox amb FOR UPDATE SKIP LOCKED i waitForConfirms; els consumidors de comandes.saga i comandes.clients amb processarUnCop i la màquina d'estats; GET /v1/comandes/{id} amb ETag; i una prova manual que porta la comanda de l'Ana de PENDENT a CONFIRMADA.
Tot això ho hem comprovat a mà, amb curl i un script. No serveix com a xarxa de seguretat: demà algú tocarà traductorProducte o el consumidor de la saga i ningú no repetirà els sis passos. La lliçó següent converteix aquestes comprovacions en proves automàtiques a diferents nivells: unitàries del domini (Comanda, transicionar), de component contra crearApp amb dobles, d'integració amb PostgreSQL i RabbitMQ reals mitjançant Testcontainers, i de contracte amb Pact entre Comandes i Catàleg, perquè el GET /v1/productes?ids= que avui funciona no es trenqui en silenci.
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
