A 04-02 vam escriure servei-cataleg, a 04-04 servei-comandes i entre 03-04 i 07-01 el gateway. Els altres quatre serveis del mapa de 01-05 —Inventari, Pagaments, Notificacions i Clients— els hem descrit moltes vegades (les seves taules a 02-04, els seus esdeveniments a 02-05 i 03-02, els seus contractes a 03-01, la seva seguretat al mòdul 7) però mai no els hem escrit. Aquesta lliçó els completa. No hi ha cap tècnica nova: cada servei fa servir la mateixa plantilla plantilla-servei-node (04-01), els mateixos mòduls de @techcorp/comu-http i la mateixa estructura de carpetes que Comandes, de manera que mostrarem complet i comentat el que és diferent en cadascun (el seu consumidor i el seu cas d'ús clau) i resumirem en taules el que és igual.

Al final tindrem el sistema sencer: el mapa definitiu d'esdeveniments, el diagrama de seqüència de la saga amb els sis serveis reals, el recorregut de la comanda com-88213 de l'Ana per cada base de dades i cada cua amb els seus temps, i la taula de pactes i proves E2E que cada servei aporta. El desplegament i l'operació de tot plegat queden per a 08-03.

Contingut

  1. Estat de partida: el que ja està escrit
  2. El que és comú als quatre serveis nous
  3. servei-inventari
  4. servei-pagaments
  5. servei-notificacions
  6. servei-clients
  7. Mapa final d'esdeveniments i la saga amb els sis serveis
  8. El recorregut de com-88213 pel sistema complet
  9. Proves: quins pactes i quines E2E afegeix cada servei

  1. Estat de partida: el que ja està escrit

Peça On es va escriure Què conté Què li falta per al sistema complet
servei-cataleg (3001) 04-02 (+ Redis a 06-04, HPA, autenticar({ opcional: true }) a 07-01) crearApp({ repositori, logger, comprovacionsSalut }), productesServei/productesRepositori sobre MongoDB, GET /v1/productes en tres formes, scripts/llavor.js (p-501, p-777, p-802) Res: publica producte.actualitzat (invalidació de la memòria cau de 06-04, analítica); només el consumia durant la migració (08-01)
servei-comandes (3002) 04-04 (+ resiliència 06-03, telemetria 06-02, autenticar/regla del propietari 07-01, auditoria 07-03) Agregat Comanda, crearComanda amb Idempotency-Key, desarAmbEsdeveniments, relay de l'outbox, consumidorSaga (comandes.saga), consumidorClients (comandes.clients), vigilantSaga, GET /v1/comandes/{id} amb ETag Que els seus quatre col·laboradors existeixin de debò
Gateway (8080) 03-04, 07-01 Rutes `/api/v1/productes comandes
bff-mobil (3010) 03-03, 03-04 GraphQL sobre Catàleg, Comandes i Clients Només referenciat: no el toquem
@techcorp/comu-http 04-01, 03-02, 06-01, 06-03, 07-01, 07-03 crearLogger, middlewareRequestId, respondreProblema, middlewareErrors, crearRutesSalut, ErrorNegoci, missatgeria/{topologia,publicador,consumidor,outbox,idempotencia}, crearClientHttp, reintentar, crearCircuitBreaker, autenticar/requereixRol/requereixScope, crearAuditoria, mètriques Res

Sobre l'última fila, un recordatori de 08-01 (fase 2): el relay de l'outbox i processarUnCop van néixer a Comandes (04-04) i es van moure a la llibreria tan bon punt Inventari els va necessitar. En aquesta lliçó els farem servir des de @techcorp/comu-http/missatgeria/outbox (crearOutbox(bd)encuar(tx, esdeveniments), crearRelayOutbox) i .../missatgeria/idempotencia (crearIdempotencia(bd)processarUnCop(esdevenimentId, consumidor, fn)), amb exactament el codi de 04-04 §5 i §7.

  1. El que és comú als quatre serveis nous

Els quatre neixen de la plantilla i comparteixen aquesta forma; a cada apartat només escriurem els fitxers marcats amb ★.

servei-<nom>/
├── src/
│   ├── servidor.js  app.js  config.js  salut.js  telemetria.js      # 04-02, 04-03, 06-02: idèntics tret dels noms
│   ├── rutes/*.js                                                    # només els endpoints de la taula de cada servei
│   ├── casos-us/*.js                                       ★         # la lògica de negoci del servei
│   ├── domini/*.js                                                   # estats i regles pures
│   ├── repositoris/*.js                                              # SQL del servei; sense lògica
│   ├── clients/*.js  (adaptadors a sistemes externs)       ★         # passarel·la, correu, Keycloak: ACL (02-03)
│   ├── infra/postgres.js                                             # el de 04-04 §2, copiat (utilitat de 30 línies, 02-02 §6)
│   └── missatgeria/consumidor<Cua>.js                      ★         # sobre crearConsumidor de la llibreria (06-03 §8)
├── migracions/NNN-*.sql   scripts/migrar.js                          # 04-04 §2
├── contractes/openapi.yaml  contractes/asyncapi.yaml                 # 03-06
└── .github/workflows/ci.yml  (9 línies: uses servei-node-ci.yml@v1, 05-03 §11)

I el consumidor genèric de la llibreria, tal com va quedar a 06-03 §8, amb la signatura que faran servir els tres consumidors d'aquesta lliçó:

// @techcorp/comu-http/missatgeria/consumidor.js — signatura (el cos és el de 06-03 §8)
// crearConsumidor({ canal, cua, routingKeys, gestors, idempotencia, logger, prefetch = 10, maxIntents = 5, ttlReintentMs = 30000 })
//   - declara <cua>, <cua>.reintent (TTL) i <cua>.dlq amb declararCuaAmbReintent
//   - per cada missatge: parseja el sobre {esdevenimentId, tipus, versio, ocorregutEn, carrega}; cerca gestors[tipus]
//   - executa idempotencia.processarUnCop(sobre.esdevenimentId, cua, (tx) => gestor(sobre, tx))  → els duplicats no repeteixen efectes
//   - ack si acaba; err.transitori === true i x-intents < maxIntents → <cua>.reintent; si no → <cua>.dlq
//   - propaga traceparent (06-02) i requestId als logs
// Retorna { iniciar, aturar }.

Convenció d'errors als gestors: throw Object.assign(new Error('...'), { transitori: true }) per a "torna-ho a intentar d'aquí a 30 s" (BD o dependència caiguda, esdeveniment que arriba abans d'hora) i ErrorNegoci o error normal per a "això no s'arreglarà sol" (DLQ a la primera). És la decisió que INC-2031 (06-05) va convertir en regla.

  1. servei-inventari

Port 3006, equip de Comandes, PostgreSQL inventari. És el servei que al monòlit era un UPDATE estoc dins de crearComanda i ara és l'amo exclusiu de l'invariant reservat <= quantitat (02-04 §7.2).

Responsabilitat Estoc disponible, reserves amb caducitat, consum i alliberament; entrades de magatzem
Endpoints POST /v1/reserves (201 / 409 SENSE_ESTOC; Idempotency-Key), DELETE /v1/reserves/{id} (204), GET /v1/estoc/{producteId} ({ producteId, disponible, reservat }), PUT /v1/estoc/{producteId}/entrades (magatzem, rol operador); tots interns, amb autenticar + requereixRol('servei' | 'operador')
Consumeix (cua inventari.comandes) comanda.creada → reservar; comanda.confirmada → consumir; comanda.cancellada → alliberar
Publica estoc.reservat, estoc.rebutjat, estoc.alliberat, estoc.reposat (entrades de magatzem; avui sense consumidor)
Taules estoc, reserves, linies_reserva (02-04 §7.2), outbox, esdeveniments_processats
Tasques caducarReserves cada 60 s (reserves ACTIVA amb expira_en < now()), CronJob reconciliar-reserves (06-03 §9)
config.js PORT=3006, INVENTARI_DB_URL (Secret inventari-db), RABBITMQ_URL (Secret inventari-rabbitmq), RESERVA_TTL_S=900, CADUCITAT_INTERVAL_MS=60000, OUTBOX_INTERVAL_MS, COMANDES_URL (només la reconciliació), LOG_NIVELL, OTEL_*, KEYCLOAK_ISSUER/AUDIENCE

El cas d'ús clau és la reserva. Tot passa en una transacció local: bloqueig de les files d'estoc, comprovació, escriptura de la reserva i esdeveniment a l'outbox. És la T2 de la saga de 02-05.

// src/casos-us/reservarEstoc.js
const { randomUUID } = require('node:crypto');

function crearCasUsReservarEstoc({ bd, outbox, ttlSegons, logger }) {
  // Retorna { estat: 'RESERVADA' | 'REBUTJADA', reservaId?, faltants? }. S'executa amb el tx de processarUnCop
  // (consumidor) o amb un de propi (POST /v1/reserves). Idempotent per comandaId gràcies a reserves.comanda_id UNIQUE.
  return async function reservarEstoc({ comandaId, linies, clientId, total }, tx) {
    // 1. Ja existeix una reserva per a aquesta comanda? (relliurament, o POST repetit): retornar la mateixa, sense tocar l'estoc
    const previa = (await tx.consultar('SELECT reserva_id, estat FROM reserves WHERE comanda_id = $1', [comandaId])).rows[0];
    if (previa) return { estat: previa.estat === 'ACTIVA' || previa.estat === 'CONSUMIDA' ? 'RESERVADA' : 'REBUTJADA', reservaId: previa.reserva_id };

    // 2. Bloquejar les files d'estoc implicades, SEMPRE en el mateix ordre (per producte_id) perquè dues comandes
    //    amb els mateixos productes no es bloquegin creuadament (deadlock). FOR UPDATE: ningú més no les toca fins al COMMIT.
    const ids = [...new Set(linies.map((l) => l.producteId))].sort();
    const { rows: estoc } = await tx.consultar('SELECT producte_id, quantitat, reservat FROM estoc WHERE producte_id = ANY($1) ORDER BY producte_id FOR UPDATE', [ids]);
    const perProducte = new Map(estoc.map((s) => [s.producte_id, s]));

    // 3. Comprovar TOTES les línies abans de reservar-ne cap: la reserva és tot o res
    const faltants = linies.filter((l) => { const s = perProducte.get(l.producteId); return !s || s.quantitat - s.reservat < l.quantitat; }).map((l) => l.producteId);
    if (faltants.length > 0) {
      await outbox.encuar(tx, [{ tipus: 'estoc.rebutjat', carrega: { comandaId, motiu: 'SENSE_ESTOC', productesSenseEstoc: faltants } }]);
      logger.info({ comandaId, faltants }, 'reserva rebutjada');
      return { estat: 'REBUTJADA', faltants };                        // sense reserva: no hi ha res a alliberar després
    }

    // 4. Reservar: incrementar reservat (el CHECK reservat <= quantitat de 02-04 és l'última xarxa) i crear la reserva
    const reservaId = `res-${randomUUID().slice(0, 8)}`;
    const expiraEn = new Date(Date.now() + ttlSegons * 1000);
    for (const l of linies) await tx.consultar('UPDATE estoc SET reservat = reservat + $1 WHERE producte_id = $2', [l.quantitat, l.producteId]);
    await tx.consultar(`INSERT INTO reserves (reserva_id, comanda_id, estat, expira_en) VALUES ($1,$2,'ACTIVA',$3)`, [reservaId, comandaId, expiraEn]);
    for (const l of linies) await tx.consultar('INSERT INTO linies_reserva (reserva_id, producte_id, quantitat) VALUES ($1,$2,$3)', [reservaId, l.producteId, l.quantitat]);

    // 5. L'esdeveniment surt per l'outbox en la MATEIXA transacció (02-05 §7): o reserva + esdeveniment, o res
    await outbox.encuar(tx, [{ tipus: 'estoc.reservat', carrega: { comandaId, reservaId, linies, expiraEn: expiraEn.toISOString() } }]);
    logger.info({ comandaId, reservaId, expiraEn }, 'estoc reservat');
    return { estat: 'RESERVADA', reservaId };
  };
}
module.exports = { crearCasUsReservarEstoc };

Els altres dos casos d'ús són curts i simètrics: consumirReserva(comandaId, tx) passa la reserva ACTIVA a CONSUMIDA i executa UPDATE estoc SET quantitat = quantitat - c, reservat = reservat - c per línia (el descompte definitiu que al monòlit era dins de la transacció de crearComanda); alliberarReserva(comandaId, tx) és literalment l'esquelet de 02-05 §5 (ALLIBERADA, reservat - c, esdeveniment estoc.alliberat). Tots dos comencen amb "si no hi ha reserva o no està ACTIVA, no fer res": la meitat de la idempotència. El consumidor només encamina:

// src/missatgeria/consumidorComandes.js
const { crearConsumidor } = require('@techcorp/comu-http/missatgeria/consumidor');

function crearConsumidorComandes({ canal, idempotencia, reservarEstoc, consumirReserva, alliberarReserva, logger }) {
  return crearConsumidor({
    canal, cua: 'inventari.comandes', idempotencia, logger, prefetch: 10,
    routingKeys: ['comanda.creada', 'comanda.confirmada', 'comanda.cancellada'],
    gestors: {
      // La càrrega de comanda.creada (02-05 §4) porta linies[{producteId, quantitat}]: Inventari no consulta ningú
      'comanda.creada':     (sobre, tx) => reservarEstoc({ comandaId: sobre.carrega.comandaId, linies: sobre.carrega.linies }, tx),
      'comanda.confirmada': (sobre, tx) => consumirReserva(sobre.carrega.comandaId, tx),
      'comanda.cancellada': (sobre, tx) => alliberarReserva(sobre.carrega.comandaId, tx)     // C2 de la saga; també per a TIMEOUT_PAGAMENT
    }
  });
}
module.exports = { crearConsumidorComandes };

I la caducitat, la xarxa de seguretat independent del vigilant de Comandes (02-05 §5, 06-03 §9): cada minut, UPDATE reserves SET estat='ALLIBERADA' WHERE estat='ACTIVA' AND expira_en < now() RETURNING … dins d'una transacció que també resta reservat i encua estoc.alliberat amb motiu: 'CADUCADA', amb FOR UPDATE SKIP LOCKED perquè dues rèpliques no es trepitgin. Si mai caduca una reserva d'una comanda que no està CANCELLADA, alguna cosa falla a la saga: es registra amb error i ho detecta la reconciliació. POST /v1/reserves reutilitza reservarEstoc dins de bd.transaccio i respon 201 + Location o 409 SENSE_ESTOC amb productesSenseEstoc al problema (03-01 §5.5).

  1. servei-pagaments

Port 3003, equip de Pagaments i comunicacions, PostgreSQL pagaments. És l'únic servei que parla amb la passarel·la externa i l'únic amb sortida a Internet (07-04).

Responsabilitat Cobrar quan hi ha estoc reservat, reemborsar quan una comanda cobrada es cancel·la, rebre confirmacions de la passarel·la, desar mètodes de pagament tokenitzats
Endpoints POST /v1/webhooks/passarella (HMAC, 07-02 §9, només referenciat), POST /v1/metodes-pagament (client: desa el tok_… que el navegador va obtenir de la passarel·la, 07-03), GET /v1/pagaments?comandaId= (operador), POST /v1/pagaments/{id}/reemborsaments (operador; auditat)
Consumeix (cua pagaments.estoc) comanda.creada → registrar el pagament PENDENT (import, clientId); estoc.reservat → cobrar; comanda.cancellada → reemborsar si estava CAPTURAT, anul·lar si PENDENT
Publica pagament.confirmat, pagament.rebutjat, pagament.reemborsat
Taules pagaments (pagament_id, comanda_id UNIQUE, client_id, import, moneda, estat, referencia_passarella, intents, creat_en, actualitzat_en), metodes_pagament (client_id, referencia_passarella, predeterminat), webhooks_rebuts, auditoria, outbox, esdeveniments_processats
config.js PORT=3003, PAGAMENTS_DB_URL, RABBITMQ_URL, PASSARELLA_URL, PASSARELLA_API_KEY i PASSARELLA_WEBHOOK_SECRET (Secret pagaments-passarella), PASSARELLA_TIMEOUT_MS=5000, PAGAMENT_NOU_PROVEIDOR=false (flag), OUTBOX_INTERVAL_MS, LOG_NIVELL, OTEL_*

Un matís de disseny que 02-05 deixava implícit ("Pagaments cobra sense consultar ningú") i que aquí es fa explícit: Pagaments també escolta comanda.creada, perquè estoc.reservat és un esdeveniment d'Inventari que no ha de portar imports ni clients. En rebre comanda.creada registra el pagament en PENDENT amb import i clientId; en rebre estoc.reservat cobra. Si per concurrència estoc.reservat arriba abans d'haver processat comanda.creada, el gestor llança un error transitori i el missatge torna d'aquí a 30 s per la cua de reintent (06-03): l'ordenació es resol amb el mecanisme que ja existeix, no amb un de nou. Els estats del pagament són els de 02-03 (AUTORITZAT, CAPTURAT, REBUTJAT, REEMBORSAT) més tres d'implementació: PENDENT (registrat, sense parlar encara amb la passarel·la), EN_CURS (crida en marxa) i ANULLAT (cancel·lat abans de cobrar).

L'adaptador a la passarel·la és un ACL amb les quatre proteccions de 06-03 (ex. 1) i el branch by abstraction del flag:

// src/clients/passarellaClient.js — ACL cap a la passarel·la: la resta de Pagaments només veu { ok, referencia, motiu }
const pLimit = require('p-limit');
const { crearClientHttp, reintentar, crearCircuitBreaker } = require('@techcorp/comu-http');

function crearPassarellaClient({ urlBase, apiKey, timeoutMs, nouProveidor, logger, metriques }) {
  const http = crearClientHttp({ urlBase, timeoutMs, nom: 'passarella', capcaleres: { Authorization: `Bearer ${apiKey}` } });
  const breaker = crearCircuitBreaker({ nom: 'passarella', enCanviar: ({ estat }) => metriques.breaker.set({ dependencia: 'passarella' }, estat) });
  const limit = pLimit(10);                                                           // bulkhead: 10 cobraments simultanis per rèplica
  // Dos proveïdors, mateixa interfície: el flag PAGAMENT_NOU_PROVEIDOR tria el traductor (02-02 §4.2, 04-03 §8)
  const traduir = nouProveidor ? require('./traductors/proveidorB') : require('./traductors/proveidorA');

  async function cridar(ruta, cos, { comandaId, requestId }) {
    // Idempotency-Key = comandaId: si el primer intent va cobrar i la resposta es va perdre, el segon retorna el MATEIX cobrament
    return limit(() => breaker.executar(() => reintentar(
      () => http.peticio(ruta, { metode: 'POST', cos, requestId, capcaleres: { 'Idempotency-Key': comandaId } }),
      { intents: 3, baseMs: 500, reintentarSi: (err) => err.transitori === true }      // 503/timeout sí; 402 (rebutjat) no
    )));
  }
  return {
    // cobrar → { ok: true, referencia } | { ok: false, motiu: 'TARGETA_REBUTJADA' | 'FONS_INSUFICIENTS' | ... }
    cobrar: async ({ comandaId, import: quantia, moneda, referenciaClient, requestId }) =>
      traduir.cobrament(await cridar(traduir.rutaCobrament, traduir.cosCobrament({ comandaId, import: quantia, moneda, referenciaClient }), { comandaId, requestId })),
    reemborsar: async ({ comandaId, referencia, import: quantia, requestId }) =>
      traduir.reemborsament(await cridar(traduir.rutaReemborsament(referencia), { import: quantia }, { comandaId: `${comandaId}-reemborsament`, requestId })),
    consultar: ({ comandaId, requestId }) => cridar(traduir.rutaConsulta, { clau: comandaId }, { comandaId, requestId }).then(traduir.cobrament)
  };
}
module.exports = { crearPassarellaClient };

I el cas d'ús de cobrament. Fixa't que la crida a la passarel·la va fora de la transacció (l'error 5 de crearComanda a 01-05: mai una crida externa dins d'una transacció) i en com se sobreviu a una caiguda entre el cobrament i el COMMIT:

// src/casos-us/cobrarComanda.js
function crearCasUsCobrarComanda({ bd, outbox, passarella, logger }) {
  // S'invoca des del gestor d'estoc.reservat. NO rep tx: gestiona les seves pròpies transaccions curtes,
  // perquè entremig hi ha una crida externa de fins a 5 s que no ha de mantenir files bloquejades.
  return async function cobrarComanda({ comandaId, requestId }) {
    // 1. Transacció curta: llegir el pagament i marcar-lo EN_CURS (si ja està CAPTURAT/REBUTJAT: duplicat, sortir)
    const pagament = await bd.transaccio(async (tx) => {
      const p = (await tx.consultar('SELECT * FROM pagaments WHERE comanda_id = $1 FOR UPDATE', [comandaId])).rows[0];
      if (!p) throw Object.assign(new Error('pagament encara no registrat (comanda.creada no processada)'), { transitori: true });   // → reintent 30 s
      if (p.estat !== 'PENDENT' && p.estat !== 'EN_CURS') return null;                                                             // ja resolt: idempotent
      await tx.consultar(`UPDATE pagaments SET estat = 'EN_CURS', intents = intents + 1, actualitzat_en = now() WHERE pagament_id = $1`, [p.pagament_id]);
      return { ...p, reintentDespresDeCaiguda: p.estat === 'EN_CURS' };
    });
    if (!pagament) return;

    // 2. Fora de la transacció: parlar amb la passarel·la. Si el procés va morir amb el pagament EN_CURS, primer es CONSULTA
    //    per la clau d'idempotència (va arribar a cobrar-se?) abans de tornar a cobrar (06-03 ex. 1).
    const metode = (await bd.consultar('SELECT referencia_passarella FROM metodes_pagament WHERE client_id = $1 AND predeterminat', [pagament.client_id])).rows[0];
    let resultat;
    if (!metode) resultat = { ok: false, motiu: 'SENSE_METODE_PAGAMENT' };
    else if (pagament.reintentDespresDeCaiguda) resultat = await passarella.consultar({ comandaId, requestId }).catch(() => null);
    if (!resultat) resultat = await passarella.cobrar({ comandaId, import: pagament.import, moneda: pagament.moneda, referenciaClient: metode.referencia_passarella, requestId });
    // (si la passarel·la llança un error transitori després d'esgotar els reintents, es propaga: el consumidor l'envia a .reintent i el pagament continua EN_CURS)

    // 3. Transacció curta: resultat + esdeveniment, junts. UNIQUE(comanda_id) i l'estat garanteixen "una comanda, un cobrament".
    await bd.transaccio(async (tx) => {
      if (resultat.ok) {
        await tx.consultar(`UPDATE pagaments SET estat = 'CAPTURAT', referencia_passarella = $2, actualitzat_en = now() WHERE pagament_id = $1`, [pagament.pagament_id, resultat.referencia]);
        await outbox.encuar(tx, [{ tipus: 'pagament.confirmat', carrega: { comandaId, pagamentId: pagament.pagament_id, import: Number(pagament.import), moneda: pagament.moneda } }]);
      } else {
        await tx.consultar(`UPDATE pagaments SET estat = 'REBUTJAT', actualitzat_en = now() WHERE pagament_id = $1`, [pagament.pagament_id]);
        await outbox.encuar(tx, [{ tipus: 'pagament.rebutjat', carrega: { comandaId, pagamentId: pagament.pagament_id, motiu: resultat.motiu } }]);
      }
    });
    logger.info({ comandaId, pagamentId: pagament.pagament_id, ok: resultat.ok, motiu: resultat.motiu }, 'cobrament resolt');
  };
}
module.exports = { crearCasUsCobrarComanda };

El consumidor de pagaments.estoc té la mateixa forma que el d'Inventari, amb tres gestors: 'comanda.creada'INSERT INTO pagaments (pagament_id, comanda_id, client_id, import, moneda, estat) VALUES ('pag-…', $1, $2, $3, 'EUR', 'PENDENT') ON CONFLICT (comanda_id) DO NOTHING; 'estoc.reservat'cobrarComanda; 'comanda.cancellada'reemborsarSiEscau: si el pagament està CAPTURAT, passarella.reemborsar i pagament.reemborsat (C3 de 02-05, la branca rara: TIMEOUT_PAGAMENT just després de cobrar, o cancel·lació del client dins del termini); si està PENDENT, ANULLAT sense cridar ningú (SENSE_ESTOC és el cas habitual). El webhook de 07-02 §9 fa el mateix que el pas 3 de cobrarComanda quan la passarel·la confirma de manera asíncrona (cobraments diferits, contracàrrecs), i cada reemborsament manual passa per auditoria.registrar (07-03 §9).

  1. servei-notificacions

Port 3005, equip de Pagaments i comunicacions. Va néixer de serveis/correu.js del monòlit i és deliberadament el servei més simple: sense API de negoci, un consumidor i un adaptador de correu.

Responsabilitat Enviar el correu de confirmació o de cancel·lació de cada comanda, un sol cop
Endpoints Només /health/* i /metrics (no exposat al gateway)
Consumeix (cua notificacions.comandes) comanda.creada → desar destinatari; comanda.confirmada → correu CONFIRMACIO; comanda.cancellada → correu CANCELLACIO amb el motiu
Publica Res (avui). notificacio.enviada queda com a extensió
Taules destinataris (comanda_id PK, email, nom, creat_en) amb retenció de 7 dies (07-03 §8), enviaments (enviament_id, comanda_id, tipus, destinatari, estat, enviat_en, UNIQUE (comanda_id, tipus)), esdeveniments_processats
config.js PORT=3005, NOTIFICACIONS_DB_URL, RABBITMQ_URL, CORREU_PROVEIDOR=consola|http, CORREU_URL, CORREU_API_KEY (Secret notificacions-correu), [email protected], CORREU_TIMEOUT_MS=3000, LOG_NIVELL, OTEL_*

Sobre destinataris: 07-03 va decidir que les dades personals viatgen només a comanda.creada. Per això Notificacions se subscriu també a aquest esdeveniment (el binding extra que proposava l'exercici 1 de 03-02) i desa email/nom set dies; comanda.confirmada i comanda.cancellada arriben sense. Si una comanda.confirmada arriba abans que la seva comanda.creada (possible amb dues rèpliques), el gestor llança transitori i espera 30 s: el mateix patró que Pagaments.

// src/clients/correuClient.js — adaptador amb dos modes: 'consola' (desenvolupament, E2E) i 'http' (proveïdor fictici)
const { crearClientHttp, reintentar } = require('@techcorp/comu-http');

function crearCorreuClient({ proveidor, urlBase, apiKey, remitent, timeoutMs, logger }) {
  if (proveidor === 'consola') {
    return { enviar: async (m) => { logger.info({ a: m.a, assumpte: m.assumpte }, '[correu consola]'); return { proveidorId: `consola-${Date.now()}` }; } };
  }
  const http = crearClientHttp({ urlBase, timeoutMs, nom: 'correu', capcaleres: { Authorization: `Bearer ${apiKey}` } });
  return {
    // enviar({ a, assumpte, html, text, referencia }) → { proveidorId }. `referencia` (comandaId:tipus) és la clau d'idempotència
    // del proveïdor: si reintentem després d'un timeout, no envia dos correus. Un 4xx (adreça invàlida) NO és transitori → DLQ.
    enviar: (m) => reintentar(() => http.peticio('/v1/missatges', { metode: 'POST', cos: { de: remitent, ...m }, capcaleres: { 'Idempotency-Key': m.referencia } }),
                              { intents: 2, baseMs: 300 })
  };
}
module.exports = { crearCorreuClient };
// src/casos-us/notificarComanda.js — el gestor de comanda.confirmada / comanda.cancellada
const plantilles = require('../domini/plantilles');   // confirmacio(c) i cancellacio(c, motiu) → { assumpte, html, text }; MOTIUS → text llegible
const TIPUS = { 'comanda.confirmada': 'CONFIRMACIO', 'comanda.cancellada': 'CANCELLACIO' };

function crearCasUsNotificarComanda({ correu, logger }) {
  return async function notificarComanda(sobre, tx) {
    const { comandaId } = sobre.carrega, tipus = TIPUS[sobre.tipus];
    // 1. Destinatari desat en rebre comanda.creada. Si encara no hi és: transitori (ha arribat abans d'hora)
    const dest = (await tx.consultar('SELECT email, nom FROM destinataris WHERE comanda_id = $1', [comandaId])).rows[0];
    if (!dest) throw Object.assign(new Error(`sense destinatari per a ${comandaId}`), { transitori: true });
    // 2. Idempotència de negoci a més de la d'esdevenimentId: una comanda, un correu de cada tipus (UNIQUE (comanda_id, tipus)).
    //    Si una comanda.confirmada es reemetés amb UN ALTRE esdevenimentId (reprocessat des de la DLQ, 06-03), tampoc no duplicaria.
    const { rowCount } = await tx.consultar(`INSERT INTO enviaments (enviament_id, comanda_id, tipus, destinatari, estat) VALUES (gen_random_uuid(), $1, $2, $3, 'ENVIANT') ON CONFLICT DO NOTHING`, [comandaId, tipus, dest.email]);
    if (rowCount === 0) { logger.info({ comandaId, tipus }, 'correu ja enviat, ignorat'); return; }
    // 3. Enviar. Si falla de manera transitòria, la transacció fa ROLLBACK (la fila ENVIANT desapareix) i el missatge
    //    torna d'aquí a 30 s; si el proveïdor va cobrar l'enviament però vam perdre la resposta, la seva Idempotency-Key evita el duplicat.
    const missatge = sobre.tipus === 'comanda.confirmada' ? plantilles.confirmacio({ ...sobre.carrega, nom: dest.nom }) : plantilles.cancellacio({ ...sobre.carrega, nom: dest.nom }, sobre.carrega.motiu);
    const { proveidorId } = await correu.enviar({ a: dest.email, referencia: `${comandaId}:${tipus}`, ...missatge });
    await tx.consultar(`UPDATE enviaments SET estat = 'ENVIAT', proveidor_id = $3, enviat_en = now() WHERE comanda_id = $1 AND tipus = $2`, [comandaId, tipus, proveidorId]);
    logger.info({ comandaId, tipus, proveidorId }, 'correu enviat');
  };
}
module.exports = { crearCasUsNotificarComanda };

Aquí la crida externa que va dins de la transacció de processarUnCop, a diferència de Pagaments. És una decisió conscient i convé entendre per què és acceptable: la transacció no bloqueja files que altres necessitin (només enviaments de la mateixa comanda), el timeout és de 3 s, i el benefici —que una fallada d'enviament desfaci la marca ENVIANT i el registre a esdeveniments_processats alhora— simplifica molt el codi. A Pagaments, amb 5 s de passarel·la i diners pel mig, la mateixa decisió seria dolenta. Les plantilles (domini/plantilles.js) tradueixen els motius de comanda.cancellada a llenguatge de client: SENSE_ESTOC → "el producte s'ha esgotat", PAGAMENT_REBUTJAT → "no hem pogut cobrar", TIMEOUT_PAGAMENT → "no s'ha completat el pagament a temps", CLIENT_PENEDIT → "hem cancel·lat la teva comanda tal com vas demanar".

  1. servei-clients

Port 3004, equip d'Experiència de compra, PostgreSQL clients. Va ser l'últim a extreure's (08-01, fase 6) i substitueix l'stub scripts/stubClients.js que arrossegàvem des de 04-04.

Responsabilitat Perfil i adreces del client; alta vinculada a la identitat de Keycloak; dret de supressió
Endpoints POST /v1/clients (alta amb el token de l'usuari acabat de registrar a Keycloak), GET /v1/clients/{id} (el mateix client, operador/admin o servei amb clients:llegir, 07-01 ex. 2), PUT /v1/clients/{id} (el mateix o operador; publica client.actualitzat), DELETE /v1/clients/{id} (el mateix o admin; RGPD), `GET
Consumeix Res
Publica client.actualitzat (→ comandes.clients, rèplica clients_ref), client.eliminat
Taules clients (client_id, keycloak_sub UNIQUE, email UNIQUE, nom, actiu, creat_en, actualitzat_en), adreces (adreca_id, client_id FK, carrer, cp, ciutat, pais, predeterminada), outbox, auditoria
config.js PORT=3004, CLIENTS_DB_URL, RABBITMQ_URL, KEYCLOAK_URL=https://auth.techcorp.example, KEYCLOAK_REALM=techcorp, KEYCLOAK_ADMIN_CLIENT_ID=servei-clients i KEYCLOAK_ADMIN_CLIENT_SECRET (Secret clients-keycloak; client client credentials amb el rol manage-users del realm), KEYCLOAK_ISSUER/AUDIENCE, OUTBOX_INTERVAL_MS, LOG_NIVELL, OTEL_*

El cas d'ús que tanca el cercle amb 07-01 és l'alta: l'Ana es registra a Keycloak (formulari del realm), la botiga fa login i crida POST /v1/clients amb el seu token; aquest token encara no té el claim clientId. Clients crea c-…, escriu l'atribut a Keycloak i, al següent refresh, el token ja viatja amb clientId: "c-1024".

// src/casos-us/altaClient.js
const { randomUUID } = require('node:crypto');
const { ErrorNegoci } = require('@techcorp/comu-http');

function crearCasUsAltaClient({ bd, outbox, keycloak, logger }) {
  // usuari = req.usuari d'autenticar() (07-01): { sub, email, nom }. El cos pot portar una adreça inicial.
  return async function altaClient({ nom, adreca }, usuari) {
    if (!usuari.sub) throw new ErrorNegoci('NO_AUTENTICAT', 'alta sense identitat', 401);
    // 1. Idempotent per keycloak_sub: repetir el POST retorna el mateix client (el navegador reintenta sovint aquí)
    const existent = (await bd.consultar('SELECT client_id FROM clients WHERE keycloak_sub = $1', [usuari.sub])).rows[0];
    if (existent) return { clientId: existent.client_id, repetit: true };

    const clientId = `c-${randomUUID().slice(0, 8)}`;
    // 2. Client + adreça + esdeveniment en UNA transacció; email des del token, no des del cos (07-03: no confiar en l'entrada)
    await bd.transaccio(async (tx) => {
      await tx.consultar(`INSERT INTO clients (client_id, keycloak_sub, email, nom, actiu) VALUES ($1,$2,$3,$4,true)`, [clientId, usuari.sub, usuari.email, nom]);
      if (adreca) await tx.consultar(`INSERT INTO adreces (adreca_id, client_id, carrer, cp, ciutat, pais, predeterminada) VALUES ($1,$2,$3,$4,$5,$6,true)`,
                                     [`adr-${randomUUID().slice(0, 6)}`, clientId, adreca.carrer, adreca.cp, adreca.ciutat, adreca.pais ?? 'ES']);
      await outbox.encuar(tx, [{ tipus: 'client.actualitzat', carrega: { clientId, nom, email: usuari.email, actualitzatEn: new Date().toISOString() } }]);
    });
    // 3. Fora de la transacció: escriure l'atribut a Keycloak (crida externa; reintentable i idempotent: PUT de l'atribut).
    //    Si falla, el client existeix igualment i un job 'sincronitzarKeycloak' ho reintenta: el token sense clientId només
    //    impedeix crear comandes uns minuts (403 a Comandes, 07-01), no compra res malament.
    try { await keycloak.establirAtribut(usuari.sub, 'clientId', clientId); }
    catch (err) { logger.error({ err, clientId, sub: usuari.sub }, "no s'ha pogut escriure clientId a Keycloak; pendent de sincronitzar"); await bd.consultar('INSERT INTO keycloak_pendents (client_id) VALUES ($1) ON CONFLICT DO NOTHING', [clientId]); }
    logger.info({ clientId }, "client donat d'alta");
    return { clientId, repetit: false };
  };
}
module.exports = { crearCasUsAltaClient };

Els altres dos casos d'ús, resumits: PUT /v1/clients/{id} valida amb zod (07-03 §3: nom ≤ 120 caràcters, sense HTML), aplica la regla del propietari o operador, fa UPDATE clients SET nom, actualitzat_en = now() i encua client.actualitzat en la mateixa transacció; el consumidor comandes.clients de 04-04 l'aplica sobre clients_ref amb la protecció per actualitzatEn de l'exercici 2 de 04-04. DELETE /v1/clients/{id} (RGPD, 07-03 §8) anonimitza en lloc d'esborrar (email = 'anon-<hash>@eliminat', nom = 'Client eliminat', actiu = false, adreces esborrades), encua client.eliminat, deshabilita l'usuari a Keycloak, i registra CLIENT_ELIMINAT a auditoria amb el sub de qui ho ha demanat; Comandes consumeix client.eliminat a comandes.clients per esborrar la fila de clients_ref i anonimitzar adreces de comandes antigues. keycloakClient (src/clients/keycloakClient.js) és crearClientHttp + crearProveidorToken (07-01 §8) sobre l'API d'administració (PUT /admin/realms/techcorp/users/{sub} amb attributes.clientId).

  1. Mapa final d'esdeveniments i la saga amb els sis serveis

Amb els quatre serveis escrits, aquest és el mapa definitiu. Respecte de la taula de 03-02 hi ha dos bindings més (comanda.creada a pagaments.estoc i a notificacions.comandes), un consumidor més de client.* (analítica, 08-01 §8) i tots els esdeveniments de compensació amb productor real:

Esdeveniment Productor Consumidors (cua) Càrrega essencial
comanda.creada Comandes Inventari (inventari.comandes), Pagaments (pagaments.estoc), Notificacions (notificacions.comandes), analítica comandaId, clientId, client{email,nom}, adrecaEnviament, linies[], total
estoc.reservat Inventari Comandes (comandes.saga), Pagaments (pagaments.estoc) comandaId, reservaId, linies, expiraEn
estoc.rebutjat Inventari Comandes (comandes.saga) comandaId, motiu: SENSE_ESTOC, productesSenseEstoc[]
pagament.confirmat Pagaments Comandes (comandes.saga) comandaId, pagamentId, import, moneda
pagament.rebutjat Pagaments Comandes (comandes.saga) comandaId, pagamentId, motiu
comanda.confirmada Comandes Inventari, Notificacions, analítica comandaId, clientId, linies, total (sense dades personals)
comanda.cancellada Comandes (consumidor de saga, vigilant, DELETE /v1/comandes/{id}) Inventari, Pagaments, Notificacions, analítica comandaId, motiu
estoc.alliberat Inventari (ningú avui; analítica) comandaId, reservaId, motiu?
pagament.reemborsat Pagaments (ningú avui; analítica) comandaId, pagamentId, import
client.actualitzat / client.eliminat Clients Comandes (comandes.clients), analítica clientId, nom, email, actualitzatEn / clientId
producte.actualitzat Catàleg analítica producteId, nom, preu, categoria, publicat
sequenceDiagram
    autonumber
    actor Ana
    participant GW as gateway :8080
    participant PED as servei-comandes :3002
    participant CLI as servei-clients :3004
    participant CAT as servei-cataleg :3001
    participant MQ as RabbitMQ techcorp.esdeveniments
    participant INV as servei-inventari :3006
    participant PAG as servei-pagaments :3003
    participant PSP as Passarel·la
    participant NOT as servei-notificacions :3005
    Ana->>GW: POST /api/v1/comandes (JWT clientId=c-1024, Idempotency-Key)
    GW->>PED: POST /v1/comandes + X-Usuari-*
    par validacions síncrones
        PED->>CLI: GET /v1/clients/c-1024 (token de servei)
        PED->>CAT: GET /v1/productes?ids=p-501,p-777
    end
    PED->>PED: tx: comanda PENDENT + outbox(comanda.creada)
    PED-->>Ana: 202 Location /v1/comandes/com-88213
    PED--)MQ: comanda.creada (relay)
    MQ--)INV: inventari.comandes
    MQ--)PAG: pagaments.estoc → pagament PENDENT
    MQ--)NOT: notificacions.comandes → destinatari
    INV->>INV: tx: FOR UPDATE, reserva ACTIVA + outbox(estoc.reservat)
    INV--)MQ: estoc.reservat
    MQ--)PED: comandes.saga → ESTOC_RESERVAT
    MQ--)PAG: pagaments.estoc → cobrar
    PAG->>PSP: POST cobrament (Idempotency-Key = comandaId)
    PSP-->>PAG: ok, referencia
    PAG->>PAG: tx: CAPTURAT + outbox(pagament.confirmat)
    PAG--)MQ: pagament.confirmat
    MQ--)PED: comandes.saga → PAGADA → CONFIRMADA + outbox(comanda.confirmada)
    PED--)MQ: comanda.confirmada
    MQ--)INV: reserva CONSUMIDA, quantitat -= c
    MQ--)NOT: correu CONFIRMACIO
    NOT->>Ana: "Comanda com-88213 confirmada"

  1. El recorregut de com-88213 pel sistema complet

La comanda de l'Ana (c-1024, p-501 ×1 i p-777 ×2, 79,70 €), amb temps ficticis coherents amb la traça de 06-02 §8 (gateway 3 ms, Comandes 180 ms, Catàleg 40 ms, Clients 25 ms):

Instant (UTC) Servei Què passa Fila / missatge que apareix
10:42:00.000 gateway POST /api/v1/comandes; JWT vàlid, clientId=c-1024 = cos log requestId=7f3c…, traceparent
10:42:00.003 comandes Promise.all: Clients (25 ms) i Catàleg (40 ms)
10:42:00.180 comandes (PG comandes) COMMIT comandes(com-88213, PENDENT, 79.70), 2 linies_comanda, clients_ref(c-1024), outbox(evt-a1, comanda.creada), claus_idempotencia
10:42:00.183 gateway → Ana 202 Accepted, Location: /v1/comandes/com-88213
10:42:00.420 comandes (relay) publica i marca outbox.publicat_en; a techcorp.esdeveniments → 4 cues
10:42:00.470 pagaments (PG pagaments) comanda.creada pagaments(pag-9001, com-88213, c-1024, 79.70, PENDENT), esdeveniments_processats(evt-a1, pagaments.estoc)
10:42:00.475 notificacions comanda.creada destinataris(com-88213, [email protected], Ana Ruiz)
10:42:00.490 inventari (PG inventari) FOR UPDATE p-501, p-777; reserva estoc.reservat p-501 +1, p-777 +2; reserves(res-4471, ACTIVA, expira 10:57:00), 2 linies_reserva, outbox(evt-b2, estoc.reservat), esdeveniments_processats(evt-a1, inventari.comandes)
10:42:00.720 inventari (relay) publica estoc.reservatcomandes.saga, pagaments.estoc
10:42:00.760 comandes comandes.saga comandes.estat = ESTOC_RESERVAT, esdeveniments_processats(evt-b2, comandes.saga); el GET de l'Ana retornaria aquest estat
10:42:00.790 pagaments pagaments.estoc: EN_CURS; crida a la passarel·la (1,2 s) pagaments.estat = EN_CURS, intents = 1
10:42:02.010 pagaments resposta OK ch_7f3a… pagaments.estat = CAPTURAT, referencia_passarella, outbox(evt-c3, pagament.confirmat), esdeveniments_processats(evt-b2, pagaments.estoc)
10:42:02.260 pagaments (relay) → comandes pagament.confirmat a comandes.saga comandes.estat = CONFIRMADA (PAGADA → CONFIRMADA en la mateixa transacció, 04-04 §8), outbox(evt-d4, comanda.confirmada), esdeveniments_processats(evt-c3, comandes.saga)
10:42:02.500 comandes (relay) publica comanda.confirmadainventari.comandes, notificacions.comandes, analítica
10:42:02.540 inventari consumir reserves(res-4471) = CONSUMIDA; estoc p-501 quantitat −1, reservat −1; p-777 −2/−2
10:42:02.900 notificacions correu enviaments(com-88213, CONFIRMACIO, ENVIAT, proveidor_id); l'Ana rep el correu
10:42:02.9 mètriques saga_durada_segons observa 2,7 s; comandes_creades_total +1 panell "Saga de comandes" (06-01)

Compara-ho amb el diagrama de 01-05 §5: són les mateixes vuit operacions de negoci, però en cinc transaccions locals en quatre bases de dades, unides per sis missatges, cadascuna amb la seva marca d'idempotència. I si a les 10:42:00.490 no hi hagués estoc: estoc.rebutjatCANCELLADA (SENSE_ESTOC) a les 10:42:00.8, comanda.cancellada → Pagaments anul·la el PENDENT sense cridar ningú, Notificacions envia "producte esgotat", Inventari no té cap reserva a alliberar. I si la passarel·la rebutgés: pagament.rebutjatCANCELLADA (PAGAMENT_REBUTJAT) → Inventari allibera (estoc.alliberat), Notificacions "no hem pogut cobrar".

  1. Proves: quins pactes i quines E2E afegeix cada servei

Cada servei arriba amb la piràmide de 04-05 (unitàries del domini, component amb crearApp i dobles, integració amb Testcontainers). El que suma al conjunt són els contractes i les E2E:

Servei Pactes (consumidor → proveïdor) E2E que afegeix a plataforma/proves/e2e/
Inventari HTTP: reconciliar-reserves → Comandes GET /v1/comandes/{id} (estat 'existeix com-… CANCELLADA'). Missatges: Inventari com a consumidor de comanda.creada (Comandes verifica que la seva càrrega compleix linies[{producteId, quantitat}]); Inventari com a proveïdor d'estoc.reservat per a Comandes i Pagaments (l'acció correctiva d'INC-2031: linies mai null) senseEstoc.e2e.test.js: comanda amb p-802 (estoc 0) → CANCELLADA amb motiu SENSE_ESTOC en < 5 s i GET /v1/estoc/p-802 sense reservat
Pagaments Missatges: consumidor de comanda.creada i estoc.reservat; proveïdor de pagament.confirmat/pagament.rebutjat per a Comandes. Cap a la passarel·la: no hi ha Pact (tercer) → proves de component contra un servidor fals amb els casos 200/402/503/timeout/duplicat (06-03 §11) pagamentRebutjat.e2e.test.js: mètode de pagament fictici tok_rebutjarCANCELLADA (PAGAMENT_REBUTJAT), estoc.alliberat i correu CANCELLACIO en mode consola
Notificacions Missatges: consumidor de comanda.creada, comanda.confirmada, comanda.cancellada (Comandes verifica) Es cobreix amb les anteriors comprovant enviaments (una fila per comanda i tipus)
Clients HTTP: Comandes → Clients GET /v1/clients/{id} (el pacte de l'exercici 2 de 04-05, ara verificat pel servei real en lloc de l'stub); Clients com a proveïdor de client.actualitzat/client.eliminat per a Comandes clientEliminat.e2e.test.js: DELETE /v1/clients/{id}clients_ref desapareix a Comandes i GET /v1/clients/{id} → 404
(ja existent) Comandes → Catàleg GET /v1/productes?ids= (04-05) crearComanda.e2e.test.js (04-05, 05-01): ara arriba a CONFIRMADA amb els serveis reals, sense publicarEsdeveniment.js

Els pactes de missatges fan servir la mateixa eina (@pact-foundation/pact, MessageConsumerPact / MessageProviderPact) i el mateix Broker amb can-i-deploy de 05-03 §4: si Comandes canvia la càrrega de comanda.creada, el CI de Comandes sap que trenca tres consumidors abans de desplegar. És la resposta definitiva a la causa 1 d'INC-2031.

Errors Comuns i Consells

  • Copiar el consumidor de 04-04 a cada servei en lloc de fer servir crearConsumidor. Quatre còpies de la gestió d'x-intents, reintent i DLQ divergeixen en un mes. És codi tècnic: llibreria (02-02 §6).
  • Bloquejar files d'estoc en ordre diferent en dues rutes (ORDER BY producte_id a la reserva i sense ordre a l'entrada de magatzem). Dues transaccions s'esperen creuadament i PostgreSQL en mata una amb 40P01. Sempre el mateix ordre.
  • La crida a la passarel·la dins de la transacció "per simplificar". Cinc segons amb la fila de pagaments bloquejada i, pitjor, un ROLLBACK que desfà el registre d'un cobrament que la passarel·la sí que va fer. Transaccions curtes al voltant; consulta per clau d'idempotència si hi ha dubte.
  • Confiar només en esdevenimentId per no enviar dos correus. Un reprocés des de la DLQ o una comanda.confirmada reemesa porten un altre esdevenimentId. La idempotència de negoci (UNIQUE (comanda_id, tipus)) és la que protegeix el client.
  • Posar email a comanda.confirmada "perquè Notificacions ho necessita". 07-03 ho va restringir a comanda.creada; la solució és que Notificacions ho desi set dies, no reobrir la decisió.
  • Escriure l'atribut a Keycloak dins de la transacció de l'alta. És una crida externa; si Keycloak triga, l'INSERT espera; si falla, el client no existeix i l'usuari tampoc no pot reintentar (ja està registrat a Keycloak). Fora, reintentable, amb cua de pendents.
  • Consell: quan escriguis el quart servei amb la mateixa plantilla, mesura quant trigues. Si són hores i no dies, la fase 0 de 08-01 i la regla del Luis han funcionat; si són dies, alguna cosa de la plantilla o de la llibreria falta i cal pujar-la abans del cinquè.

Exercicis

Exercici 1: El client cancel·la a temps

Afegeix a servei-comandes la cancel·lació pel client (DELETE /v1/comandes/{id} de 03-01, motiu CLIENT_PENEDIT), permesa només en CONFIRMADA durant 30 minuts. Indica quina transició afegiries a la màquina d'estats de 02-05, quin esdeveniment es publica i què fa cadascun dels quatre serveis d'aquesta lliçó en rebre'l (una línia per servei, amb la taula o crida implicada).

Exercici 2: estoc.reservat abans que comanda.creada

A Pagaments, estoc.reservat pot arribar abans que comanda.creada estigui processada. Explica (a) per què és possible encara que Comandes publiqui comanda.creada abans que existeixi estoc.reservat; (b) què passa pas a pas amb el disseny d'aquesta lliçó (transitori, cua de reintent, x-intents); (c) quina alternativa hi hauria si en lloc d'un error transitori volguéssim resoldre-ho sense esperar 30 s, i quin cost té.

Exercici 3: Un pacte de missatges

Escriu, en pseudocodi o amb l'API de Pact per a missatges, el contracte que Pagaments declara com a consumidor d'estoc.reservat: quins camps exigeix, amb quins matchers, i quin provider state hauria de preparar Inventari per verificar-lo. Explica què hauria detectat aquest pacte el 22 de juliol de 2026 (INC-2031).

Solucions

Exercici 1. Transició CONFIRMADA --cancellar.client--> CANCELLADA (amb la comprovació now() - actualitzat_en < 30 min al cas d'ús, no a la taula de transicions), motiu CLIENT_PENEDIT, auditoria COMANDA_CANCELLADA (07-03) i esdeveniment comanda.cancellada { comandaId, motiu } per l'outbox. Reaccions: Inventari (inventari.comandes): la reserva ja està CONSUMIDA, així que alliberarReserva no fa res; cal un cas nou, reposarPerCancellacio: si la reserva està CONSUMIDA, UPDATE estoc SET quantitat = quantitat + c per línia i estoc.reposat (o marcar-la RETORNADA); Pagaments (pagaments.estoc): reemborsarSiEscau troba el pagament CAPTURATpassarella.reemborsar amb clau com-…-reemborsamentREEMBORSAT + pagament.reemborsat (la branca C3 de 02-05, ara habitual); Notificacions: correu CANCELLACIO amb el text de CLIENT_PENEDIT (ja existeix a les plantilles), idempotent per (comanda_id, CANCELLACIO); Clients: res (no consumeix esdeveniments de comanda). I analítica registra la cancel·lació amb el motiu.

Exercici 2. (a) comanda.creada entra a pagaments.estoc abans que estoc.reservat, però la cua té diversos missatges en vol (prefetch 10) i dues rèpliques: la rèplica A pren comanda.creada i la seva transacció triga 40 ms; la rèplica B pren estoc.reservat 30 ms després i l'executa abans que A faci COMMIT. L'ordre d'una cua és de lliurament, no de finalització. (b) cobrarComanda no troba la fila → llança { transitori: true }crearConsumidor publica el missatge a pagaments.estoc.reintent amb x-intents: 1 i fa ack de l'original → 30 s després torna per techcorp.esdeveniments.reintent → ara la fila existeix → cobrament normal. Cost: 30 s més de saga per a aquesta comanda (dins de l'SLO de 60 s de 06-05, però comptant); amb maxIntents 5 hi hauria 2 minuts de marge abans de la DLQ. (c) Alternativa: al gestor d'estoc.reservat, si no hi ha pagament, crear la fila PENDENT amb les dades del mateix esdeveniment —cosa que exigiria que estoc.reservat portés import i clientId, acoblant Inventari amb dades que no són seves— o consultar GET /v1/comandes/{id} de manera síncrona (acoblament temporal, un altre client HTTP, un altre pacte). Totes dues eviten l'espera a costa de més acoblament; TechCorp accepta els 30 s ocasionals perquè són rars (només amb concurrència real) i el mecanisme ja existeix.

Exercici 3.

// pagaments/proves/contracte/estocReservat.consumidor.pact.test.js (esquema)
const missatger = new MessageConsumerPact({ consumer: 'servei-pagaments', provider: 'servei-inventari', dir: 'pactes' });
await missatger
  .given('existeix una reserva ACTIVA per a com-88213')                 // provider state que Inventari prepara amb el seu repositori en memòria
  .expectsToReceive("estoc.reservat d'una comanda amb línies")
  .withContent({ esdevenimentId: like('evt-b2'), tipus: 'estoc.reservat', versio: integer(1), ocorregutEn: iso8601DateTime(),
                 carrega: { comandaId: regex(/^com-[0-9a-f]{8}$/, 'com-88213'), reservaId: like('res-4471'),
                            linies: eachLike({ producteId: like('p-501'), quantitat: integer(1) }, { min: 1 }), expiraEn: iso8601DateTime() } })
  .verify(async (missatge) => gestorEstocReservat(missatge.contents, txFalsa));    // el gestor REAL de Pagaments amb la càrrega del pacte

Exigeix comandaId amb el format de 07-03, reservaId, linies com a array d'almenys un element amb producteId i quantitat enters, i expiraEn com a data; amb like/eachLike en lloc de valors exactes (04-05: tipus i forma, no dades). Inventari, com a proveïdor, verifica al seu CI que el seu publicador produeix un missatge que compleix aquest contracte per a aquest estat. El 22 de juliol, Inventari 2.3.0 va publicar linies: null: la verificació del proveïdor hauria fallat al CI d'Inventari abans de construir la imatge, can-i-deploy hauria dit que no, i pagaments.estoc no s'hauria encallat. (Tot i que el gestor de Pagaments que fallava amb TypeError era el d'estoc.reservat, que avui no fa servir linies; el pacte documenta igualment el que Pagaments tolera i el que no.)

Conclusió

El sistema de TechCorp està complet. Als tres components ja escrits —servei-cataleg (04-02), servei-comandes (04-04) i el gateway (03-04/07-01)— hi hem afegit, amb la mateixa plantilla i la mateixa llibreria, els quatre que faltaven: Inventari, amo de l'invariant reservat <= quantitat, amb la reserva tot-o-res sota FOR UPDATE en ordre fix, estoc.reservat/estoc.rebutjat per outbox, consum i alliberament idempotents i la caducitat de reserves com a xarxa independent; Pagaments, amb l'ACL passarellaClient (clau d'idempotència = comandaId, reintents només del que és transitori, circuit breaker, bulkhead, dos proveïdors darrere d'un flag), transaccions curtes al voltant de la crida externa, UNIQUE (comanda_id) i la branca de reemborsament; Notificacions, amb destinataris de set dies per respectar la decisió RGPD de 07-03, enviaments amb UNIQUE (comanda_id, tipus) com a idempotència de negoci i l'adaptador de correu amb mode consola; i Clients, amb l'alta que escriu el claim clientId a Keycloak fora de la transacció, client.actualitzat per a la rèplica de Comandes i client.eliminat per al dret de supressió. El mapa d'esdeveniments ha quedat tancat (onze tipus, cinc cues més la d'analítica), la saga s'ha dibuixat amb els sis serveis reals, com-88213 ha travessat quatre bases de dades i sis missatges en 2,9 segons, i cada servei ha aportat els seus pactes (HTTP i de missatges) i les seves E2E.

Tot això són repositoris amb codi i proves en verd. La lliçó següent els porta a un clúster: el repositori techcorp/plataforma complet, el compose.yaml amb els sis serveis i tota la infraestructura, l'ordre d'arrencada en un clúster nou, la prova de fum amb un token de Keycloak, i l'operació del dia a dia —un desplegament de Comandes de punta a punta, la campanya de Black Friday, un incident a la DLQ, la rotació d'un secret, l'actualització de Node i una evolució de contracte— amb el seu cost mensual aproximat.

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