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

  1. Estructura del projecte i dependències
  2. PostgreSQL: pool, transaccions i migracions
  3. Clients HTTP sortints: Catàleg (amb ACL) i Clients
  4. Domini: l'agregat Comanda i la màquina d'estats
  5. Repositori amb outbox: desarAmbEsdeveniments() i processarUnCop()
  6. El cas d'ús crearComanda i la ruta POST /v1/comandes
  7. El relay de l'outbox
  8. Consumidors: la saga i la rèplica de clients
  9. GET /v1/comandes/{id} amb ETag i l'arrencada completa
  10. Prova de cap a cap

  1. Estructura del projecte i dependències

npm install express pg amqplib pino pino-http zod @techcorp/comu-http && npm install -D nodemon dotenv
servei-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.

  1. 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).

  1. 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; 404ErrorNegoci('CLIENT_NO_EXISTEIX', ..., 404); 5xx/xarxa → DEPENDENCIA_NO_DISPONIBLE; retorna { clientId, nom, email, adreces }. Sense reintents ni circuit breaker (06-03): només timeout.

  1. Domini: l'agregat Comanda i la màquina d'estats

domini/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 };

  1. Repositori amb outbox: 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.

  1. El cas d'ús 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.

  1. 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.

  1. 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).

  1. 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

  1. 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és outbox; el relay publica.
  • Un esdevenimentId nou a cada intent del relay. Els consumidors no reconeixerien el duplicat. El sobre reutilitza outbox.esdeveniment_id.
  • ack abans de processar o efecte fora de la transacció de processarUnCop. Es perden esdeveniments o s'apliquen dues vegades. Efecte i registre amb el mateix tx; ack al final.
  • Consultar Clients i Catàleg en sèrie. Promise.all: són independents. (I si un falla, Promise.all rebutja 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.80 no sempre és 79.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 UPDATE sense SKIP LOCKED. Amb dues rèpliques, la segona espera la primera a cada cicle; amb SKIP LOCKED treballen 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

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