En separar les bases de dades hem perdut, a consciència, la peça que feia còmode el monòlit: la transacció única de crearComanda, aquell BEGIN ... COMMIT que reservava estoc, creava la comanda, registrava el pagament i, si alguna cosa fallava, ho desfeia tot amb un ROLLBACK. Ara la reserva viu a la base de dades d'Inventari, el pagament a la de Pagaments i la comanda a la de Comandes, i no existeix cap ROLLBACK que abasti les tres. L'esquema de 02-04 va deixar les pistes: estats ESTOC_RESERVAT i PAGADA a comandes, expira_en a reserves, un motiu_cancellacio. Aquesta lliçó explica què substitueix aquesta transacció.

Veurem per què no hi ha transaccions ACID entre serveis (i per què el two-phase commit es descarta), què significa a la pràctica el teorema CAP i la consistència eventual, el patró saga en les seves dues variants (coreografia i orquestració), el disseny complet de la saga "crear comanda" de TechCorp amb els esdeveniments del curs, les seves transaccions compensatòries i la màquina d'estats de la comanda, la decisió raonada de començar per coreografia, el patró outbox transaccional que garanteix que un esdeveniment es publica si i només si el canvi s'ha desat, la idempotència dels consumidors, i les dues idees que solen acompanyar les sagues: CQRS (models separats d'escriptura i lectura) i event sourcing (desar esdeveniments en lloc d'estat), amb la decisió de TechCorp sobre cadascuna. Tot a nivell de disseny: com es configura RabbitMQ i com es publica un esdeveniment des de Node.js es veu a 03-02 i 04-04.

Contingut

  1. Per què no hi ha transaccions ACID entre serveis
  2. El teorema CAP i la consistència eventual, en termes pràctics
  3. El patró saga: coreografia enfront d'orquestració
  4. La saga "crear comanda" de TechCorp
  5. La màquina d'estats de la comanda
  6. La decisió de TechCorp: coreografia per començar
  7. El patró outbox transaccional
  8. Idempotència dels consumidors i claus d'idempotència
  9. CQRS: separar el model d'escriptura del de lectura
  10. Event sourcing: desar els fets en lloc de l'estat

  1. Per què no hi ha transaccions ACID entre serveis

Una transacció ACID (atòmica, consistent, aïllada, durable) és una promesa que fa un motor de base de dades sobre les seves dades. Tan bon punt les dades són en dos motors (PostgreSQL de Comandes i PostgreSQL d'Inventari, o PostgreSQL i MongoDB), cap dels dos no pot prometre res sobre l'altre.

Hi ha un protocol per coordinar diverses bases de dades en una sola transacció: el two-phase commit (2PC, "confirmació en dues fases"). Un coordinador pregunta a tots els participants "podeu confirmar?" (fase 1: prepare); si tots diuen que sí, ordena "confirmeu" (fase 2: commit); si algun diu que no, ordena "avorteu". Sona perfecte, i en microserveis es descarta gairebé sempre per aquestes raons:

Problema del 2PC Per què és greu en microserveis
Bloquejos llargs. Entre la fase 1 i la 2, cada participant manté les seves files bloquejades esperant el coordinador. És exactament el problema de la passarel·la dins de la transacció de crearComanda, però multiplicat per la xarxa: les files d'estoc quedarien bloquejades mentre Pagaments parla amb la passarel·la.
El coordinador és un punt únic de fallada. Si cau entre fases, els participants queden "en dubte", bloquejats fins que torni. Viola el disseny per a la fallada: una caiguda del coordinador paralitza inventari, comandes i pagaments alhora.
Acoblament temporal total. Tots han d'estar disponibles en el mateix instant. La disponibilitat del conjunt és el producte de les disponibilitats.
Suport desigual. No tots els magatzems el suporten (MongoDB no participa en 2PC amb PostgreSQL), ni els sistemes externs (la passarel·la de pagament no "prepararà" un cobrament). El flux de TechCorp inclou una passarel·la externa: el 2PC és impossible al pas més delicat.
Escala malament. El rendiment cau amb el nombre de participants i la latència entre ells. Contradiu el motiu d'escalat pel qual s'adopten microserveis.

La conclusió de la indústria, i de TechCorp: entre serveis no hi ha atomicitat; hi ha seqüències de transaccions locals, cadascuna atòmica dins del seu servei, coordinades per missatges, i amb accions de compensació per desfer el que s'ha fet quan un pas posterior falla. Això és una saga.

  1. El teorema CAP i la consistència eventual, en termes pràctics

El teorema CAP (Brewer) diu que un sistema distribuït, davant d'una partició de xarxa (P: dues parts no es poden comunicar), ha de triar entre consistència (C: tothom veu la mateixa dada en el mateix instant) i disponibilitat (A: tothom rep resposta). Com que les particions passen (fal·làcia 1 de 01-02: la xarxa no és fiable), l'elecció real és "quan la xarxa falli, prefereixo respondre amb dades possiblement desactualitzades o prefereixo no respondre?".

Per al flux de comanda de TechCorp, la resposta és matisada:

  • Dins de cada servei, consistència forta: servei-inventari no reservarà mai més del que hi ha (reservat <= quantitat és una restricció de la seva base de dades).
  • Entre serveis, consistència eventual: durant uns segons, la comanda està PENDENT a Comandes mentre Inventari ja ha reservat; la web pot mostrar "processant" i el client rebrà el correu quan tot convergeixi. És acceptable perquè el negoci ho tolera: ningú no necessita saber en el mateix mil·lisegon que l'estoc i la comanda estan d'acord.

"Eventual" significa "garantit, però no instantani": si deixen d'arribar canvis, totes les còpies acaben coincidint. No significa "de vegades": un sistema que perd esdeveniments no és eventualment consistent, és incorrecte. Els apartats 7 i 8 (outbox i idempotència) són precisament el que converteix "eventual" en "garantit".

El que canvia per a l'equip, dit sense teoria: entre que es crea la comanda i es confirma poden passar segons, i en aquest interval el sistema és en un estat intermedi legítim que cal modelar, mostrar i saber desfer. Al monòlit aquest interval no existia per a ningú de fora; ara forma part del disseny.

  1. El patró saga: coreografia enfront d'orquestració

Una saga és una seqüència de transaccions locals T1, T2, ..., Tn, cadascuna en un servei diferent, on cada Ti publica un missatge que dispara Ti+1. Si Ti falla, s'executen les transaccions compensatòries Ci-1, ..., C1 que desfan semànticament l'anterior. "Semànticament" és important: no és un ROLLBACK (el cobrament ja s'ha fet), és una acció de negoci inversa (un reemborsament).

Hi ha dues maneres de coordinar la seqüència:

Aspecte Coreografia Orquestració
Qui decideix el pas següent Ningú de central: cada servei reacciona als esdeveniments que li interessen i publica els seus. Un orquestrador (un component dins d'un servei, o un servei dedicat) envia ordres a cada participant i n'espera les respostes.
Estil de missatges Esdeveniments ("ha passat X"): comanda.creada, estoc.reservat. Ordres ("fes X"): reservarEstoc, cobrar, més respostes.
On és la lògica del flux Repartida: Inventari sap que davant de comanda.creada reserva; Pagaments sap que davant d'estoc.reservat cobra. Concentrada a l'orquestrador, que coneix tots els passos i compensacions.
Acoblament Baix: els serveis no es coneixen entre si, només coneixen esdeveniments. L'orquestrador coneix tothom; els participants no es coneixen entre si.
Visibilitat de l'estat global Difícil: cal reconstruir-lo a partir dels esdeveniments (traces, 06-02). Fàcil: l'orquestrador té l'estat de cada saga a la seva taula.
Risc Amb molts passos, ningú no entén el flux complet ("qui reacciona a què?"); dependències cícliques ocultes. L'orquestrador es converteix en un "cervell" central que acumula lògica d'altres contextos (torna crearComanda amb un altre nom) si no es disciplina.
Afegir un pas Afegir un subscriptor; no es toca ningú més. Modificar l'orquestrador.
Compensacions Cada servei se subscriu a l'esdeveniment de fallada/cancel·lació i compensa el que és seu. L'orquestrador les invoca en ordre invers.
Quan encaixa Fluxos curts (3-5 passos), lineals, amb equips autònoms. Fluxos llargs, amb bifurcacions, terminis, intervenció humana, o quan cal veure "en quin pas és la comanda 88213" des d'un sol lloc.

Cap no és "la bona". La regla pràctica: coreografia per defecte per a fluxos simples; orquestració quan el flux creix o quan la visibilitat esdevé un problema.

  1. La saga "crear comanda" de TechCorp

Dissenyem la saga amb els esdeveniments del curs. Els cinc esdeveniments principals ja són coneguts des de 01-05; l'event storming de 02-02 va destapar els de fallada i compensació, que ara anomenem amb la mateixa convenció <context>.<fet>:

Esdeveniment Publica Significat Paper a la saga
comanda.creada Comandes Comanda registrada en PENDENT amb línies, total i dades de contacte/enviament. Arrencada (T1).
estoc.reservat Inventari Reserva creada per a la comanda. T2 correcta.
estoc.rebutjat Inventari No hi ha prou estoc per a alguna línia. T2 fallida.
pagament.confirmat Pagaments La passarel·la ha acceptat el cobrament. T3 correcta.
pagament.rebutjat Pagaments La passarel·la ha rebutjat el cobrament. T3 fallida.
comanda.confirmada Comandes Comanda completa (estoc i pagament correctes). Final feliç (T4).
comanda.cancellada Comandes La comanda no es completarà; porta motiu. Dispara compensacions.
estoc.alliberat Inventari Reserva alliberada. Compensació C2.
pagament.reemborsat Pagaments Cobrament retornat. Compensació C3 (només si s'havia arribat a cobrar).

Els passos, en el camí feliç i en els dos camins de fallada:

sequenceDiagram
    autonumber
    actor C as Client
    participant GW as API Gateway :8080
    participant PED as servei-comandes
    participant INV as servei-inventari
    participant PAG as servei-pagaments
    participant NOT as servei-notificacions

    C->>GW: POST /comandes {clientId, linies} + Idempotency-Key
    GW->>PED: POST /comandes
    Note over PED: T1: valida client i preus (Clients, Catàleg)<br/>INSERT comanda PENDENT + outbox(comanda.creada)
    PED-->>C: 202 Accepted {comandaId: com-88213, estat: PENDENT}
    PED--)INV: comanda.creada
    alt hi ha estoc
        Note over INV: T2: INSERT reserva ACTIVA, UPDATE estoc.reservat
        INV--)PAG: estoc.reservat
        INV--)PED: estoc.reservat
        Note over PED: estat = ESTOC_RESERVAT
        alt cobrament acceptat
            Note over PAG: T3: cobrar a la passarel·la, INSERT pagament CAPTURAT
            PAG--)PED: pagament.confirmat
            Note over PED: T4: estat = PAGADA -> CONFIRMADA
            PED--)INV: comanda.confirmada
            PED--)NOT: comanda.confirmada
            Note over INV: reserva CONSUMIDA, estoc.quantitat -= reservat
            NOT->>C: correu "comanda confirmada"
        else cobrament rebutjat
            PAG--)PED: pagament.rebutjat
            Note over PED: estat = CANCELLADA (motiu PAGAMENT_REBUTJAT)
            PED--)INV: comanda.cancellada
            PED--)NOT: comanda.cancellada
            Note over INV: C2: reserva ALLIBERADA, estoc.reservat -= quantitat
            INV--)PED: estoc.alliberat
            NOT->>C: correu "no hem pogut cobrar"
        end
    else sense estoc
        INV--)PED: estoc.rebutjat
        Note over PED: estat = CANCELLADA (motiu SENSE_ESTOC)
        PED--)NOT: comanda.cancellada
        NOT->>C: correu "producte esgotat"
    end

Observacions de disseny, una per una:

  • La resposta al client és 202 Accepted amb estat PENDENT, no 201 amb CONFIRMADA com feia el monòlit. La comanda existeix, però no està completa; el client consulta GET /comandes/com-88213 (o rep una notificació) per veure'n la confirmació. És l'estat intermedi legítim de l'apartat 2, fet visible al contracte. (Comparat amb la resposta de 01-01, on POST /comandes retornava 201 amb PENDENT: totes dues són vàlides; el curs adopta 202 a partir d'aquí per subratllar que el processament continua. El disseny concret de l'API es tanca a 03-01.)
  • T1 conserva les validacions síncrones amb Clients i Catàleg (existència del client, preus actuals): sense elles no hi ha comanda a crear. El que surt de la petició HTTP és tota la resta.
  • Pagaments reacciona a estoc.reservat, no a comanda.creada. Cobrar abans de saber si hi ha estoc obligaria a reemborsar sovint; reservar primer i cobrar després minimitza compensacions cares. L'ordre de la saga es tria posant primer els passos més probables de fallar i més barats de compensar.
  • comanda.confirmada té dos consumidors: Notificacions (correu) i Inventari (convertir la reserva en descompte definitiu d'estoc). Al monòlit aquest descompte era dins de la transacció; ara és l'última transacció local d'Inventari.
  • Les compensacions són esdeveniments de negoci, no ordres "desfés": Inventari allibera estoc perquè la comanda s'ha cancel·lat, i publica estoc.alliberat; Pagaments reemborsaria (pagament.reemborsat) només si la comanda es cancel·la després de pagament.confirmat, cosa que en aquest flux bàsic passa si Comandes, ja PAGADA, no pogués confirmar (per exemple, per una regla antifrau posterior) o si el client cancel·la dins del termini permès. És una branca que l'equip dissenya encara que avui sigui rara.
  • L'esdeveniment porta el que el consumidor necessita. comanda.creada inclou línies (amb producteId, quantitat, preuUnitari, nom), total, email, nom del client i adrecaEnviament; així Inventari reserva sense consultar ningú, Pagaments cobra sense consultar ningú i Notificacions escriu el correu sense consultar ningú (patró conformist de 02-03). Exemple:
{
  "esdevenimentId": "evt-01J5X8Q7ZK3M",
  "tipus": "comanda.creada",
  "versio": 1,
  "ocorregutEn": "2026-08-15T10:42:00Z",
  "comandaId": "com-88213",
  "clientId": "c-1024",
  "client": { "email": "[email protected]", "nom": "Ana" },
  "adrecaEnviament": { "carrer": "Gran Vía 12", "cp": "28013", "ciutat": "Madrid" },
  "linies": [
    { "producteId": "p-501", "nom": "Auriculars BT X200", "preuUnitari": 59.90, "quantitat": 1 },
    { "producteId": "p-777", "nom": "Cable USB-C 2 m",    "preuUnitari": 9.90,  "quantitat": 2 }
  ],
  "total": 79.70
}

L'esdevenimentId únic i la versio no són ornament: el primer és la base de la idempotència (apartat 8) i la segona, del versionat de contractes (03-06). Tots els esdeveniments del curs porten aquest sobre (esdevenimentId, tipus, versio, ocorregutEn) més la seva càrrega.

  1. La màquina d'estats de la comanda

La saga es veu, des de servei-comandes, com una màquina d'estats de l'agregat Comanda. Definir-la explícitament és el que evita que un esdeveniment tardà o duplicat deixi una comanda en un estat absurd (per exemple, un pagament.confirmat que arriba per a una comanda ja CANCELLADA).

stateDiagram-v2
    [*] --> PENDENT: POST /comandes (T1)
    PENDENT --> ESTOC_RESERVAT: estoc.reservat
    PENDENT --> CANCELLADA: estoc.rebutjat (SENSE_ESTOC)
    ESTOC_RESERVAT --> PAGADA: pagament.confirmat
    ESTOC_RESERVAT --> CANCELLADA: pagament.rebutjat (PAGAMENT_REBUTJAT)
    ESTOC_RESERVAT --> CANCELLADA: termini esgotat (TIMEOUT_PAGAMENT)
    PAGADA --> CONFIRMADA: confirmar (T4) → comanda.confirmada
    PAGADA --> CANCELLADA: no confirmable → reemborsament
    CONFIRMADA --> [*]
    CANCELLADA --> [*]
Estat Significat Esdeveniments que accepta Esdeveniments que ignora (i registra)
PENDENT Creada; esperant reserva. estoc.reservat, estoc.rebutjat pagament.* (encara no hauria d'existir)
ESTOC_RESERVAT Estoc apartat; esperant cobrament. pagament.confirmat, pagament.rebutjat, termini esgotat estoc.* duplicats
PAGADA Cobrament fet; transitori abans de confirmar. confirmació interna tota la resta
CONFIRMADA Final feliç. Publica comanda.confirmada. (cancel·lació pel client dins del termini, extensió futura) pagament.*, estoc.*
CANCELLADA Final. Publica comanda.cancellada amb motiu. cap tot (un pagament.confirmat tardà aquí obliga a un reemborsament: cas excepcional que es registra i genera alerta)

PAGADA sembla redundant (per què no passar d'ESTOC_RESERVAT a CONFIRMADA directament?). Es manté separat per dues raons: permet distingir "he registrat el pagament però encara no he publicat la confirmació" davant d'una caiguda entre totes dues escriptures, i deixa lloc per a regles de confirmació futures (antifrau, validació d'adreça) sense canviar la saga.

El termini esgotat mereix atenció: si Pagaments està caigut durant mitja hora, les comandes queden en ESTOC_RESERVAT amb estoc apartat que ningú no compra. servei-comandes inclou un vigilant (una tasca periòdica) que cancel·la per TIMEOUT_PAGAMENT les comandes que portin més de N minuts en aquest estat; i, com a xarxa de seguretat independent, reserves.expira_en permet a Inventari alliberar reserves òrfenes encara que Comandes no ho demani. Dos mecanismes, cadascun al seu context, per al mateix risc.

Esquelet de la màquina d'estats i d'un consumidor de compensació (il·lustratiu: sense accés a base de dades real ni a RabbitMQ; la implementació arriba al mòdul 4):

// domini/maquinaEstatsComanda.js  (servei-comandes) - transicions permeses
const TRANSICIONS = {
  PENDENT:        { 'estoc.reservat': 'ESTOC_RESERVAT', 'estoc.rebutjat': 'CANCELLADA' },
  ESTOC_RESERVAT: { 'pagament.confirmat': 'PAGADA', 'pagament.rebutjat': 'CANCELLADA', 'timeout.pagament': 'CANCELLADA' },
  PAGADA:         { 'confirmar': 'CONFIRMADA', 'no.confirmable': 'CANCELLADA' },
  CONFIRMADA:     {},
  CANCELLADA:     {}
};

const MOTIUS = { 'estoc.rebutjat': 'SENSE_ESTOC', 'pagament.rebutjat': 'PAGAMENT_REBUTJAT', 'timeout.pagament': 'TIMEOUT_PAGAMENT' };

// Retorna el nou estat o null si la transició no està permesa (esdeveniment tardà/duplicat).
function transicionar(estatActual, tipusEsdeveniment) {
  return TRANSICIONS[estatActual]?.[tipusEsdeveniment] ?? null;
}

// Gestor genèric d'esdeveniments de la saga dins de servei-comandes (esquelet).
async function enRebreEsdevenimentDeSaga(esdeveniment, repositori, outbox) {
  const comanda = await repositori.obtenir(esdeveniment.comandaId);
  const nouEstat = transicionar(comanda.estat, esdeveniment.tipus);
  if (!nouEstat) {                                       // p. ex. pagament.confirmat sobre CANCELLADA
    registrar.avis('transicio_ignorada', { comandaId: comanda.comandaId, de: comanda.estat, esdeveniment: esdeveniment.tipus });
    return;                                              // idempotent: no trenca, no repeteix
  }
  comanda.estat = nouEstat;
  if (nouEstat === 'CANCELLADA') comanda.motiuCancellacio = MOTIUS[esdeveniment.tipus];

  // Mateixa transacció local: desar la comanda i encuar l'esdeveniment sortint (outbox, apartat 7)
  await repositori.desarAmbEsdeveniments(comanda, [
    nouEstat === 'PAGADA'     && { tipus: 'confirmar', comandaId: comanda.comandaId },   // pas intern T4
    nouEstat === 'CONFIRMADA' && { tipus: 'comanda.confirmada', comandaId: comanda.comandaId, ...dadesPerAConsumidors(comanda) },
    nouEstat === 'CANCELLADA' && { tipus: 'comanda.cancellada',  comandaId: comanda.comandaId, motiu: comanda.motiuCancellacio, ...dadesPerAConsumidors(comanda) }
  ].filter(Boolean));
}
// consumidors/comandaCancellada.js  (servei-inventari) - compensació C2, esquelet
async function enComandaCancellada(esdeveniment, reserves, outbox) {
  const reserva = await reserves.cercarPerComanda(esdeveniment.comandaId);
  if (!reserva || reserva.estat !== 'ACTIVA') return;   // sense reserva o ja alliberada/consumida: res a fer (idempotent)

  // Transacció local d'Inventari: alliberar i anunciar
  await reserves.transaccio(async (tx) => {
    await tx.marcarAlliberada(reserva.reservaId);        // UPDATE reserves SET estat='ALLIBERADA'
    for (const linia of reserva.linies) {
      await tx.restarReservat(linia.producteId, linia.quantitat);   // UPDATE estoc SET reservat = reservat - quantitat
    }
    await outbox.encuar(tx, { tipus: 'estoc.alliberat', comandaId: esdeveniment.comandaId, reservaId: reserva.reservaId });
  });
}

Fixa't que tots dos esquelets comencen comprovant l'estat i surten sense fer res si l'esdeveniment no escau: és la meitat de la idempotència; l'altra meitat és a l'apartat 8.

  1. La decisió de TechCorp: coreografia per començar

El Luis i el seu equip trien coreografia per a la saga de comanda, per aquestes raons:

  • El flux és curt i lineal: quatre transaccions, dos punts de fallada, dues compensacions.
  • Encaixa amb el mapa de contextos de 02-03: Comandes–Inventari són partnership per esdeveniments, Pagaments i Notificacions consumeixen el llenguatge publicat. Ningú no ha de "manar" sobre ningú.
  • Reforça l'autonomia: afegir un consumidor (per exemple, un futur servei-promocions que escolti comanda.confirmada per consumir un cupó) no toca ningú.
  • L'equip aprèn amb el més senzill abans d'afegir una peça (l'orquestrador) que cal operar i monitorar.

I fixen per escrit quan passaran a orquestració, per no descobrir-ho en un incident:

Senyal Què indica
La saga supera els 5-6 passos o guanya bifurcacions (enviament parcial, pagament fraccionat, devolucions). La lògica repartida deixa de cabre al cap.
Ningú no sap respondre ràpid "en quin pas és la comanda X i per què?" sense llegir traces de quatre serveis. Falta visibilitat; un orquestrador amb la seva taula de sagues la dona.
Apareixen dependències cícliques d'esdeveniments entre serveis. La coreografia s'ha embolicat.
Cal intervenció humana o terminis complexos enmig del flux. Els orquestradors (o motors de workflow) modelen això millor.

Si arriba el moment, l'orquestrador viurà dins de servei-comandes (és el propietari del cicle de vida de la comanda) i enviarà ordres a Inventari i Pagaments; els esdeveniments públics (comanda.confirmada, comanda.cancellada) es mantindran per a Notificacions i per a qui vingui després. La màquina d'estats de l'apartat 5 és la mateixa en tots dos casos: per això es defineix ja.

  1. El patró outbox transaccional

Hi ha una fallada subtil que trencaria tota la saga si no es tracta. servei-comandes ha de fer dues coses en crear una comanda: desar la fila al seu PostgreSQL i publicar comanda.creada a RabbitMQ. Són dos sistemes diferents, per tant no hi ha cap transacció que abasti tots dos, i les dues ordenacions fallen:

  • Desar i després publicar: si el procés cau entre totes dues, la comanda existeix però ningú no ho sap: es queda PENDENT per sempre.
  • Publicar i després desar: si falla el desament, Inventari reserva estoc per a una comanda que no existeix.

És el mateix problema del dual write de 02-04, en versió missatgeria. La solució és l'outbox transaccional ("safata de sortida"):

  1. En la mateixa transacció local en què es desa la comanda, s'insereix l'esdeveniment en una taula outbox de la mateixa base de dades. O es desen tots dos, o cap: això sí que ho garanteix PostgreSQL.
  2. Un component a part (el relay) llegeix la taula outbox, publica els esdeveniments pendents al broker i els marca com a publicats. Si el relay cau, en tornar continua on era: cap esdeveniment no es perd.
  3. Com que el relay pot publicar un esdeveniment dues vegades (per exemple, publica i cau abans de marcar), el lliurament és com a mínim un cop, i els consumidors han de ser idempotents (apartat 8).
flowchart LR
    API[API de servei-comandes] -- "1. BEGIN<br/>INSERT comandes<br/>INSERT outbox<br/>COMMIT" --> BD[(PostgreSQL comandes)]
    RELAY[Relay d'outbox<br/>al mateix servei] -- "2. SELECT ... WHERE publicat_en IS NULL" --> BD
    RELAY -- "3. publicar" --> MQ[(RabbitMQ)]
    RELAY -- "4. UPDATE outbox SET publicat_en = NOW()" --> BD
    MQ -. comanda.creada .-> INV[servei-inventari]
-- Taula outbox a la base de dades de CADA servei que publica esdeveniments (comandes, inventari, pagaments, clients, catàleg)
CREATE TABLE outbox (
  esdeveniment_id TEXT PRIMARY KEY,          -- 'evt-01J5X8Q7ZK3M', viatja al sobre de l'esdeveniment
  agregat_tipus   TEXT NOT NULL,             -- 'Comanda'
  agregat_id      TEXT NOT NULL,             -- 'com-88213'  (permet publicar en ordre per agregat)
  tipus           TEXT NOT NULL,             -- 'comanda.creada'
  versio          INT  NOT NULL DEFAULT 1,
  carrega         JSONB NOT NULL,            -- el cos de l'esdeveniment
  creat_en        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  publicat_en     TIMESTAMPTZ                -- NULL = pendent de publicar
);
CREATE INDEX idx_outbox_pendents ON outbox (creat_en) WHERE publicat_en IS NULL;

I l'ús, en pseudocodi, des del repositori de Comandes (és el desarAmbEsdeveniments que ha aparegut a l'apartat 5):

// repositori/ComandaRepositori.js  (servei-comandes) - esquelet del desament amb outbox
async function desarAmbEsdeveniments(comanda, esdeveniments) {
  await bd.transaccio(async (tx) => {                        // UNA transacció local
    await tx.upsertComanda(comanda);                         // comandes + linies_comanda
    for (const esdeveniment of esdeveniments) {
      await tx.inserir('outbox', {
        esdeveniment_id: generarId('evt'), agregat_tipus: 'Comanda', agregat_id: comanda.comandaId,
        tipus: esdeveniment.tipus, carrega: esdeveniment
      });
    }
  });                                                        // COMMIT: comanda i esdeveniments, o res
  // El relay, en un altre fil/procés del mateix servei, s'encarrega de publicar. Aquí no es toca el broker.
}

Dues precisions. Primera: el relay pot ser una tasca periòdica del mateix servei (polling de la taula cada pocs centenars de mil·lisegons) o una eina de captura de canvis (CDC); TechCorp comença amb polling, que és suficient per a 3.000 comandes/dia, i el detall d'implementació es veu a 04-04. Segona: la taula outbox és també un registre d'auditoria gratuït de tot el que el servei ha comunicat a l'exterior, cosa que resultarà valuosa a 06-03.

  1. Idempotència dels consumidors i claus d'idempotència

Amb lliurament "com a mínim un cop", tot consumidor rebrà algun esdeveniment repetit tard o d'hora (reintent del relay, relliurament del broker després d'una fallada de confirmació, redesplegament enmig d'un processament). Idempotent vol dir que processar el mateix esdeveniment dues vegades produeix el mateix resultat que processar-lo una: Inventari no reserva dues vegades, Pagaments no cobra dues vegades, Notificacions no envia dos correus.

Hi ha dos mecanismes complementaris, i el disseny de TechCorp fa servir tots dos:

a) Idempotència natural pel model. Dissenyar les escriptures de manera que la repetició no canviï res:

  • reserves.comanda_id UNIQUE: un segon comanda.creada per a la mateixa comanda xoca amb la restricció i el consumidor el tracta com a "ja fet".
  • pagaments.comanda_id UNIQUE: un segon estoc.reservat no produeix un segon cobrament.
  • La màquina d'estats: un segon pagament.confirmat sobre una comanda ja PAGADA/CONFIRMADA no té transició i s'ignora.

b) Registre d'esdeveniments processats. Per a consumidors l'efecte dels quals no és una fila única (enviar un correu, cridar la passarel·la), es desa l'esdevenimentId a la mateixa transacció que l'efecte:

-- A la base de dades de cada consumidor
CREATE TABLE esdeveniments_processats (
  esdeveniment_id TEXT PRIMARY KEY,
  consumidor      TEXT NOT NULL,              -- 'notificacions.correuConfirmacio'
  processat_en    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
// Embolcall genèric d'idempotència per a un consumidor (esquelet)
async function processarUnCop(esdeveniment, consumidor, bd, gestor) {
  return bd.transaccio(async (tx) => {
    const jaVist = await tx.existeix('esdeveniments_processats', { esdeveniment_id: esdeveniment.esdevenimentId, consumidor });
    if (jaVist) return 'DUPLICAT';                      // no es repeteix l'efecte
    await gestor(esdeveniment, tx);                     // l'efecte real, dins de la transacció
    await tx.inserir('esdeveniments_processats', { esdeveniment_id: esdeveniment.esdevenimentId, consumidor });
    return 'PROCESSAT';
  });
}

Quan l'efecte és extern (cridar la passarel·la de pagament) no es pot ficar a la transacció; en aquest cas es registra abans un intent amb estat "en curs" i es fa servir la clau d'idempotència de la mateixa passarel·la (totes les passarel·les serioses n'accepten una, precisament per això): passarella.cobrar({ import: total, clauIdempotencia: comandaId }). Si es repeteix la crida, la passarel·la retorna el mateix resultat sense cobrar de nou.

c) La clau d'idempotència a l'API. El sisè problema de crearComanda a 01-05 era el "doble clic": dos POST /comandes iguals, dues comandes, dos cobraments. La solució de disseny és que el client enviï una capçalera Idempotency-Key (un UUID generat pel navegador per a aquell intent de compra) i que servei-comandes desi, a la seva transacció de T1, la parella clau → comandaId i resposta; davant d'una repetició amb la mateixa clau, retorna la mateixa resposta sense crear res. La forma exacta de la capçalera i de la resposta es tanca a 03-01; el disseny de dades és una taula més a comandes:

CREATE TABLE claus_idempotencia (
  clau         TEXT PRIMARY KEY,           -- valor de la capçalera Idempotency-Key
  comanda_id   TEXT NOT NULL,
  resposta     JSONB NOT NULL,             -- el que es va retornar el primer cop
  creada_en    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Amb outbox (els esdeveniments surten segur) i idempotència (els esdeveniments repetits no fan mal), la consistència eventual passa d'"esperem que arribi" a "garantit".

  1. CQRS: separar el model d'escriptura del de lectura

CQRS (Command Query Responsibility Segregation) és la idea de fer servir models diferents per escriure i per llegir. A 02-04 va aparèixer com a "vista materialitzada" i com a resposta al tauler d'administració amb filtres creuats; ara té nom i mecànica.

  • El model d'escriptura és l'agregat Comanda amb els seus invariants i la seva màquina d'estats, a les taules comandes i linies_comanda. Està optimitzat per decidir (puc confirmar?, puc cancel·lar?).
  • El model de lectura és una o diverses taules desnormalitzades, construïdes per un projector que consumeix esdeveniments, i optimitzades per mostrar: sense JOIN, amb exactament les columnes que necessita cada pantalla.

Per a "comandes amb nom de client i de producte" del tauler d'administració:

-- Model de lectura, mantingut NOMÉS pel projector d'esdeveniments. Mai no l'escriu l'API d'ordres.
CREATE TABLE vista_comandes_admin (
  comanda_id       TEXT PRIMARY KEY,
  client_id        TEXT NOT NULL,
  client_nom       TEXT NOT NULL,           -- de comanda.creada (i actualitzat per client.actualitzat)
  ciutat_enviament TEXT NOT NULL,           -- de comanda.creada
  productes_text   TEXT NOT NULL,           -- 'Auriculars BT X200 x1, Cable USB-C 2 m x2'
  total            NUMERIC(10,2) NOT NULL,
  estat            TEXT NOT NULL,           -- actualitzat per comanda.confirmada / comanda.cancellada
  creat_en         TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_vista_comandes_admin_dia_ciutat ON vista_comandes_admin (creat_en, ciutat_enviament);
// projectors/vistaComandesAdmin.js - esquelet del projector (consumidor idempotent d'esdeveniments)
const gestors = {
  'comanda.creada':      (e, tx) => tx.upsert('vista_comandes_admin', {
                           comanda_id: e.comandaId, client_id: e.clientId, client_nom: e.client.nom,
                           ciutat_enviament: e.adrecaEnviament.ciutat, total: e.total, estat: 'PENDENT',
                           productes_text: e.linies.map(l => `${l.nom} x${l.quantitat}`).join(', '),
                           creat_en: e.ocorregutEn }),
  'comanda.confirmada':  (e, tx) => tx.actualitzar('vista_comandes_admin', { comanda_id: e.comandaId }, { estat: 'CONFIRMADA' }),
  'comanda.cancellada':  (e, tx) => tx.actualitzar('vista_comandes_admin', { comanda_id: e.comandaId }, { estat: 'CANCELLADA' }),
  'client.actualitzat':  (e, tx) => tx.actualitzar('vista_comandes_admin', { client_id: e.clientId }, { client_nom: e.nom })
};
// S'executa embolcallat en processarUnCop(...) de l'apartat 8.

On viu: TechCorp comença amb la vista dins de servei-comandes (mateixa base de dades, taula diferent, alimentada pels seus propis esdeveniments i per client.actualitzat). És CQRS "lleuger": no cal un servei de consultes separat fins que el volum o els consumidors ho justifiquin. Si el dia de demà el tauler necessita creuar dades de cinc contextos, la vista es mou a un servei de consultes o al magatzem analític de 02-04.

Quan compensa CQRS i quan no:

Compensa No compensa
Consultes que creuen contextos, amb filtres i paginació (taulers, llistats). El detall d'una comanda: el model d'escriptura ja el retorna bé.
Lectures massives que no han de carregar el model transaccional (cerca de comandes, informes). Sistemes petits amb una sola base de dades, on un JOIN ho resol.
Quan el model d'escriptura és ric (agregat amb invariants) i el de lectura és pla. Quan afegir un projector només introdueix retard sense treure un problema real.

CQRS porta consistència eventual entre escriptura i lectura: una comanda acabada de crear triga mil·lisegons o segons a aparèixer a la vista. Per al tauler d'administració és irrellevant; per a "acabo de crear la comanda i la vull veure" cal llegir del model d'escriptura o retornar la representació a la resposta de l'ordre.

  1. Event sourcing: desar els fets en lloc de l'estat

L'event sourcing porta la idea un pas més enllà: no es desa l'estat actual de l'agregat, sinó la seqüència d'esdeveniments que el van produir, i l'estat es reconstrueix reproduint-los. La taula comandes amb la seva columna estat desapareixeria; en el seu lloc hi hauria un event store:

sequencia agregat_id tipus carrega
1 com-88213 ComandaCreada línies, total, client...
2 com-88213 EstocReservat reservaId
3 com-88213 PagamentRegistrat pagamentId, import
4 com-88213 ComandaConfirmada
// Reconstrucció de la comanda a partir dels seus esdeveniments (esquelet il·lustratiu)
function reconstruirComanda(esdeveniments) {
  return esdeveniments.reduce((comanda, e) => {
    switch (e.tipus) {
      case 'ComandaCreada':      return { ...e.carrega, estat: 'PENDENT' };
      case 'EstocReservat':      return { ...comanda, estat: 'ESTOC_RESERVAT', reservaId: e.carrega.reservaId };
      case 'PagamentRegistrat':  return { ...comanda, estat: 'PAGADA', pagamentId: e.carrega.pagamentId };
      case 'ComandaConfirmada':  return { ...comanda, estat: 'CONFIRMADA' };
      case 'ComandaCancellada':  return { ...comanda, estat: 'CANCELLADA', motiu: e.carrega.motiu };
      default: return comanda;
    }
  }, null);
}
A favor En contra
Auditoria completa per construcció: se sap què va passar, quan i en quin ordre. Complexitat: reconstruir estat, instantànies (snapshots) quan hi ha molts esdeveniments, versionat d'esdeveniments antics que ja no tenen la mateixa forma.
Consultes temporals: "com estava aquesta comanda el dimarts a les 10?". CQRS obligatori: no es pot fer WHERE estat = 'PENDENT' sobre un event store; calen projeccions per a qualsevol consulta.
Els esdeveniments ja són la font de veritat: outbox i event store convergeixen. Corba d'aprenentatge alta i eines menys madures a l'ecosistema habitual.
Encaixa en dominis on l'històric és el negoci (comptabilitat, banca, assegurances). Corregir una dada errònia no és un UPDATE: és emetre un esdeveniment de correcció.

Decisió de TechCorp: no, de moment. Justificació:

  1. L'estat de la comanda és senzill (cinc estats, dues branques) i el model relacional de 02-04 el representa sense esforç.
  2. Les necessitats d'auditoria (saber per què es va cancel·lar una comanda, quan es va cobrar) es cobreixen amb motiu_cancellacio, amb la taula outbox (que ja conserva tot el que s'ha comunicat) i amb una taula historial_estats_comanda si calgués, a una fracció del cost.
  3. L'equip està aprenent alhora sagues, outbox, idempotència, Docker, Kubernetes i observabilitat. Afegir event sourcing multiplicaria el risc de la migració sense resoldre cap dels cinc problemes de 01-05.
  4. Es pot adoptar més endavant en un sol context (Comandes o Pagaments, si un requisit regulador ho exigeix) sense tocar els altres, gràcies precisament al fet que cada servei és propietari del seu emmagatzematge.

És una decisió típica d'arquitectura: no es descarta la tècnica, es descarta ara i es deixa escrit el senyal que la reobriria.

Errors Comuns i Consells

  • Intentar recuperar el 2PC "amb una mica de codi": bloquejar estoc mentre es crida Pagaments per HTTP i s'espera la resposta. És la transacció de crearComanda amb més latència i més fallades. Si un pas pot fallar, dissenya'n la compensació.
  • Compensacions incompletes. Cada pas que modifica alguna cosa necessita la seva inversa dissenyada abans de desplegar: reservar ↔ alliberar, cobrar ↔ reemborsar, confirmar ↔ (no n'hi ha: per això és l'últim).
  • Publicar l'esdeveniment fora de la transacció. Sense outbox, es perden esdeveniments, i perdre un comanda.creada és una comanda zombi. L'outbox no és opcional.
  • Suposar que un esdeveniment arriba un sol cop i en ordre. Arribarà repetit i, de vegades, desordenat (un pagament.confirmat pot atrapar Comandes abans que l'estoc.reservat si el consumidor anava lent). La màquina d'estats i el registre d'esdeveniments processats protegeixen de totes dues coses.
  • Orquestrador que sap massa. Si algun dia es passa a orquestració, l'orquestrador envia ordres i espera respostes; no calcula estoc ni decideix cobraments. Això és de cada context.
  • CQRS i event sourcing "perquè són el més modern". CQRS només allà on una consulta ho demani; event sourcing només allà on l'històric sigui el negoci.
  • Consell: dibuixa sempre la saga com a diagrama de seqüència i com a màquina d'estats. El primer ensenya el camí feliç; la segona és la que t'obliga a pensar en els esdeveniments tardans, duplicats i fora d'ordre.

Exercicis

Exercici 1: Un esdeveniment fora d'ordre

Per un reinici del consumidor, servei-comandes rep pagament.confirmat de com-88213 abans que estoc.reservat (tots dos existeixen i són vàlids). Amb la màquina d'estats de l'apartat 5: què passa amb cada esdeveniment?, queda la comanda en un estat correcte al final?, què caldria canviar al disseny si aquesta situació fos freqüent?

Exercici 2: Dissenyar una compensació nova

TechCorp permetrà que el client cancel·li una comanda CONFIRMADA durant els 30 minuts següents. Dissenya l'ampliació de la saga: quina transició s'afegeix a la màquina d'estats, quin esdeveniment publica Comandes, què fa cada consumidor (Inventari, Pagaments, Notificacions), i què garanteix que un doble clic a "cancel·lar" no reemborsa dues vegades.

Exercici 3: Outbox o no?

servei-cataleg publica producte.actualitzat cada vegada que un operador desa una fitxa a MongoDB. Un company proposa publicar directament a RabbitMQ després de desar, "perquè MongoDB no és PostgreSQL i no tenim outbox". Explica què pot fallar, com aplicaries el patró outbox amb MongoDB (pista: una col·lecció outbox i transaccions de MongoDB, o el mateix document) i quin consumidor de TechCorp patiria si es perdés un producte.actualitzat.

Solucions

Exercici 1

La comanda és en PENDENT. Arriba pagament.confirmat: en PENDENT no hi ha transició per a aquest esdeveniment → s'ignora i es registra un avís (transicio_ignorada). Després arriba estoc.reservat: PENDENT → ESTOC_RESERVAT, correcte. Però el pagament.confirmat ja s'ha descartat, així que la comanda es quedaria en ESTOC_RESERVAT fins que el vigilant la cancel·li per TIMEOUT_PAGAMENT, i Pagaments hauria cobrat: acabaria en CANCELLADA amb un pagament.confirmat "orfe" que exigeix reemborsament. És correcte en el sentit que no hi ha cap estat impossible, però és un mal resultat de negoci. Si fos freqüent, hi ha dues millores: (a) en lloc de descartar l'esdeveniment no aplicable, aparcar-lo (desar-lo com a "pendent d'aplicar" i reintentar-lo quan canviï l'estat, o demanar al broker que el relliuri més tard); (b) fer que Pagaments inclogui a pagament.confirmat la reservaId, de manera que Comandes pugui acceptar PENDENT → PAGADA sabent que la reserva existeix. A la pràctica el desordre és rar perquè pagament.confirmat és causalment posterior a estoc.reservat, i (a) n'hi ha prou.

Exercici 2

  • Transició nova: CONFIRMADA → CANCELLADA amb esdeveniment d'entrada cancellacio.sollicitada (ordre del client via POST /comandes/{id}/cancellacio), permesa només si NOW() - confirmada_en <= 30 min i la comanda no ha sortit del magatzem (regla del context Comandes; en un disseny més complet, "enviada" seria un altre estat que bloquejaria la cancel·lació).
  • Comandes publica comanda.cancellada amb motiu: 'CANCELLADA_PEL_CLIENT' i, a la càrrega, pagamentId o comandaId perquè Pagaments localitzi el cobrament.
  • Inventari: la reserva ja està CONSUMIDA (l'estoc es va descomptar en confirmar); la seva compensació és reposar quantitat (quantitat += n) i publicar estoc.alliberat (o un estoc.reposat, si es vol distingir).
  • Pagaments: busca el pagament CAPTURAT de la comanda; si existeix, reemborsa a la passarel·la amb clau d'idempotència reemborsament-<comandaId> i publica pagament.reemborsat; si no hi ha cobrament, no fa res.
  • Notificacions: correu "comanda cancel·lada, reemborsament en curs".
  • Doble clic: la segona POST .../cancellacio troba la comanda ja CANCELLADA (sense transició → 409 o resposta idempotent); encara que arribés un segon comanda.cancellada, Pagaments el detecta per esdeveniments_processats i per l'estat REEMBORSAT de la seva fila; i la passarel·la l'atura per la clau d'idempotència. Tres capes.

Exercici 3

Pot fallar exactament el mateix que a PostgreSQL: el procés cau entre desar el document i publicar (esdeveniment perdut: la rèplica de preus de qui el consumeixi queda desactualitzada) o publica i després falla el desament (esdeveniment fantasma). El patró és el mateix amb MongoDB: (a) fer servir una transacció multidocument de MongoDB (disponible en replica sets) per escriure el document de productes i un document a la col·lecció outbox atòmicament, amb un relay que llegeixi outbox i publiqui; o (b) escriure els esdeveniments pendents dins del mateix document de producte (esdevenimentsPendents: [...]) a la mateixa operació d'escriptura, i que el relay els extregui i els buidi (variant "outbox encastada", útil sense transaccions); o (c) fer servir change streams de MongoDB com a mecanisme de CDC. Qui patiria: qualsevol consumidor que mantingui una rèplica de dades de catàleg (per exemple, la vista materialitzada del tauler si mostrés noms actualitzats, o el magatzem analític de 02-04 per a categories). servei-comandes no pateix en crear comandes perquè consulta el preu per composició síncrona; aquest va ser justament el motiu d'aquella decisió a 02-04.

Conclusió

Hem substituït la transacció única de crearComanda per un disseny distribuït complet. Sabem per què no hi ha ACID entre serveis i per què el 2PC es descarta (bloquejos, coordinador únic, sistemes externs), què significa a la pràctica la consistència eventual (estats intermedis legítims, garantits però no instantanis), i hem dissenyat la saga "crear comanda" de TechCorp per coreografia: comanda.creadaestoc.reservatpagament.confirmatcomanda.confirmada, amb els esdeveniments de fallada estoc.rebutjat i pagament.rebutjat, l'esdeveniment comanda.cancellada (amb motiu SENSE_ESTOC, PAGAMENT_REBUTJAT o TIMEOUT_PAGAMENT) i les compensacions estoc.alliberat i pagament.reemborsat. La màquina d'estats PENDENT → ESTOC_RESERVAT → PAGADA → CONFIRMADA / CANCELLADA governa quin esdeveniment s'accepta i quin s'ignora; l'outbox transaccional garanteix que un esdeveniment surt si i només si el canvi s'ha desat; la idempotència (restriccions UNIQUE, taula esdeveniments_processats, capçalera Idempotency-Key, claus de la passarel·la) fa inofensives les repeticions. Amb CQRS hem afegit la vista vista_comandes_admin alimentada per un projector, i hem decidit que l'event sourcing no compensa avui per a TechCorp, deixant escrits els senyals que reobririen la decisió (igual que els que portarien de coreografia a orquestració).

Amb això acaba el mòdul de disseny: tenim principis, un pla de descomposició amb ordre d'extracció, sis bounded contexts amb el seu mapa de relacions, una base de dades per servei amb el seu esquema, i una saga amb outbox i idempotència. El que encara no hem decidit és com parlen exactament els serveis: la forma de les APIs REST de cadascun (POST /comandes amb el seu 202, GET /productes?ids=, POST /reserves), com es publiquen i consumeixen de debò els esdeveniments a RabbitMQ, quan convé gRPC o GraphQL, què fa l'API Gateway del port 8080, com es troben els serveis entre si i com es versionen els contractes sense trencar ningú. Aquest és el mòdul 3, i comença per les APIs RESTful.

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