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
- Per què no hi ha transaccions ACID entre serveis
- El teorema CAP i la consistència eventual, en termes pràctics
- El patró saga: coreografia enfront d'orquestració
- La saga "crear comanda" de TechCorp
- La màquina d'estats de la comanda
- La decisió de TechCorp: coreografia per començar
- El patró outbox transaccional
- Idempotència dels consumidors i claus d'idempotència
- CQRS: separar el model d'escriptura del de lectura
- Event sourcing: desar els fets en lloc de l'estat
- 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.
- 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-inventarino 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.
- 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.
- 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 Acceptedamb estat PENDENT, no201amb CONFIRMADA com feia el monòlit. La comanda existeix, però no està completa; el client consultaGET /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, onPOST /comandesretornava201amb PENDENT: totes dues són vàlides; el curs adopta202a 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 acomanda.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.confirmadaté 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 depagament.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.creadainclou línies (ambproducteId,quantitat,preuUnitari,nom), total,email,nomdel client iadrecaEnviament; 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.
- 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.
- 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-promocionsque escolticomanda.confirmadaper 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.
- 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"):
- En la mateixa transacció local en què es desa la comanda, s'insereix l'esdeveniment en una taula
outboxde la mateixa base de dades. O es desen tots dos, o cap: això sí que ho garanteix PostgreSQL. - 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. - 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.
- 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 segoncomanda.creadaper a la mateixa comanda xoca amb la restricció i el consumidor el tracta com a "ja fet".pagaments.comanda_id UNIQUE: un segonestoc.reservatno produeix un segon cobrament.- La màquina d'estats: un segon
pagament.confirmatsobre 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".
- 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
comandesilinies_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.
- 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ó:
- L'estat de la comanda és senzill (cinc estats, dues branques) i el model relacional de 02-04 el representa sense esforç.
- Les necessitats d'auditoria (saber per què es va cancel·lar una comanda, quan es va cobrar) es cobreixen amb
motiu_cancellacio, amb la taulaoutbox(que ja conserva tot el que s'ha comunicat) i amb una taulahistorial_estats_comandasi calgués, a una fracció del cost. - 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.
- 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
crearComandaamb 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.confirmatpot atrapar Comandes abans que l'estoc.reservatsi 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 → CANCELLADAamb esdeveniment d'entradacancellacio.sollicitada(ordre del client viaPOST /comandes/{id}/cancellacio), permesa només siNOW() - confirmada_en <= 30 mini 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.cancelladaambmotiu: 'CANCELLADA_PEL_CLIENT'i, a la càrrega,pagamentIdocomandaIdperquè 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 publicarestoc.alliberat(o unestoc.reposat, si es vol distingir). - Pagaments: busca el pagament
CAPTURATde la comanda; si existeix, reemborsa a la passarel·la amb clau d'idempotènciareemborsament-<comandaId>i publicapagament.reemborsat; si no hi ha cobrament, no fa res. - Notificacions: correu "comanda cancel·lada, reemborsament en curs".
- Doble clic: la segona
POST .../cancellaciotroba la comanda jaCANCELLADA(sense transició → 409 o resposta idempotent); encara que arribés un segoncomanda.cancellada, Pagaments el detecta peresdeveniments_processatsi per l'estatREEMBORSATde 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.creada → estoc.reservat → pagament.confirmat → comanda.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
- Conceptes Bàsics de Microserveis
- Avantatges i Desavantatges dels Microserveis
- Comparació amb l'Arquitectura Monolítica
- Quan Adoptar Microserveis: Criteris de Decisió
- El Cas Pràctic del Curs: la Botiga Online de TechCorp
Mòdul 2: Disseny de Microserveis
- Principis de Disseny de Microserveis
- Descomposició d'Aplicacions Monolítiques
- Definició de Bounded Contexts
- Gestió de Dades: una Base de Dades per Servei
- Consistència Distribuïda: Sagues, CQRS i Event Sourcing
Mòdul 3: Comunicació entre Microserveis
- APIs RESTful
- Missatgeria Asíncrona
- Protocols de Comunicació: gRPC, GraphQL
- API Gateway i Backend for Frontend
- Descobriment de Serveis i Balanceig de Càrrega
- Contractes i Versionat d'APIs
Mòdul 4: Implementació de Microserveis
- Elecció de Tecnologies i Eines
- Desenvolupament d'un Microservei Simple
- Gestió de Configuració
- Integració Pràctica: Consumir APIs i Publicar Esdeveniments
- Proves en Microserveis: Unitàries, d'Integració i de Contracte
Mòdul 5: Desplegament i Orquestració
- Contenidors i Docker
- Orquestració amb Kubernetes
- CI/CD per a Microserveis
- Estratègies de Desplegament: Rolling, Blue-Green i Canary
- Service Mesh: Istio i Linkerd
Mòdul 6: Monitoratge i Manteniment
- Monitoratge i Logging
- Traçabilitat Distribuïda amb OpenTelemetry
- Gestió d'Errors i Recuperació
- Escalabilitat i Rendiment
- SLOs, Alertes i Gestió d'Incidents
Mòdul 7: Seguretat en Microserveis
- Autenticació i Autorització
- Seguretat en la Comunicació
- Pràctiques de Seguretat
- Seguretat en Contenidors i Kubernetes
