El mòdul de disseny va acabar amb una llista de preguntes pendents, i la primera era la més concreta: com es parlen exactament els serveis. Tenim decidit que POST /comandes respon 202 Accepted, que Comandes consulta el catàleg amb GET /productes?ids=, que Inventari exposa POST /reserves; però un contracte no és un nom de ruta: és la forma exacta de la petició, la resposta, els codis d'estat, el format dels errors i les capçaleres que totes dues bandes es comprometen a respectar. Aquesta lliçó converteix aquelles decisions de disseny en contractes REST complets.

REST és l'estil de comunicació síncrona que TechCorp farà servir en dos llocs: a l'API pública (allò que la web i l'app mòbil consumeixen a través del gateway del port 8080) i a les poques crides síncrones internes que 02-02 va deixar autoritzades (Comandes → Catàleg i Comandes → Clients). Veurem com es modelen recursos i URIs, la semàntica dels verbs i la seva idempotència, els codis d'estat que farem servir durant tot el curs, els cinc contractes clau de TechCorp amb exemples de petició i resposta, un format d'error uniforme basat en l'RFC 7807, paginació, filtratge, HATEOAS a nivell pràctic, capçaleres útils, documentació amb OpenAPI 3 i, finalment, un endpoint Express i un client fetch mínims que apliquen el contracte. El versionat es deixa per a 03-06, el gateway per a 03-04 i la resiliència (reintents, circuit breakers) per a 06-03: aquí només apareix el timeout com a bona pràctica mínima.

Contingut

  1. REST en microserveis: què és i què no és
  2. Recursos i URIs
  3. Verbs HTTP, semàntica i idempotència
  4. Codis d'estat que farem servir
  5. Els contractes REST clau de TechCorp
  6. Format d'errors uniforme: RFC 7807
  7. Paginació, filtratge i ordenació
  8. HATEOAS a nivell pràctic
  9. Capçaleres útils
  10. Documentació amb OpenAPI 3
  11. Un endpoint Express i un client fetch que apliquen el contracte

  1. REST en microserveis: què és i què no és

REST (Representational State Transfer) no és un protocol ni una llibreria: és un conjunt de restriccions de disseny sobre HTTP. Les que ens importen en microserveis són quatre:

  • Recursos identificats per URIs. Una comanda és /comandes/com-88213; un producte, /productes/p-501. La URI identifica la cosa, no l'acció.
  • Manipulació mitjançant representacions. El client no toca la fila de PostgreSQL: envia i rep representacions (JSON) del recurs.
  • Interfície uniforme. Els verbs HTTP (GET, POST, PUT, PATCH, DELETE) tenen el mateix significat a tots els serveis. Ningú no s'inventa POST /comandes/obtenir.
  • Sense estat entre peticions. Cada petició porta tot el que cal (autenticació, identificadors). Això és el que permet tenir vuit rèpliques de Catàleg darrere d'un balancejador (03-05) sense que importi quina respon.

El que REST no és: no és "qualsevol cosa que retorni JSON per HTTP". La diferència entre una API REST i una API "RPC sobre HTTP" és en l'ús de la interfície uniforme, i en microserveis importa perquè el contracte REST és l'única frontera entre equips: si l'equip de Comandes (el Luis) pot endevinar com es comporta un endpoint de Catàleg només llegint-ne la URI i el verb, hi ha menys reunions, menys documentació i menys sorpreses.

  1. Recursos i URIs

Les regles de TechCorp per anomenar URIs, alineades amb el principi de 02-01 que "els contractes expressen negoci":

Regla Bé Malament Per què
Substantius en plural /comandes, /productes /comanda, /getComandes La col·lecció és el recurs; el verb el posa HTTP.
Jerarquies per a relacions de contenció /comandes/com-88213/linies /linies?comanda=com-88213 (acceptable, però secundari) Les línies no existeixen sense la seva comanda (agregat de 02-03).
Identificadors opacs amb prefix /clients/c-1024 /clients/1024 Els ids opacs de 02-04 no filtren detalls de la BD.
Minúscules i guions /linies-comanda /liniesComanda, /linies_comanda Les URIs distingeixen majúscules; el guió és el separador llegible.
Sense extensions ni verbs /comandes/com-88213 /comandes/com-88213.json, /comandes/cancellar El format va a Accept; l'acció, al verb o a un subrecurs.
Noms de negoci, no de taula POST /reserves PATCH /estoc/{id} Ja ho vam decidir a 02-01: la reserva és el concepte, l'estoc és el detall.

Un cas que confon: accions que no encaixen en un verb. Com es cancel·la una comanda? Hi ha dues opcions respectables:

  • Tractar la cancel·lació com un canvi d'estat: PATCH /comandes/com-88213 amb {"estat": "CANCELLADA"}. Senzill, però barreja en un mateix endpoint canvis molt diferents.
  • Modelar l'acció com un subrecurs: POST /comandes/com-88213/cancellacio. Crea "una cancel·lació" de la comanda, amb el seu propi cos (motiu) i la seva pròpia validació. És la que farà servir TechCorp, perquè la saga de 02-05 tracta la cancel·lació com un fet de negoci amb motiu, no com l'edició d'un camp.

El que no farem mai és POST /comandes/com-88213/cancellar amb el verb a la URI, ni exposar l'estructura interna: /comandes/com-88213/linies sí, /linies_comanda?comanda_id= no.

  1. Verbs HTTP, semàntica i idempotència

Cada verb té un significat i dues propietats que en sistemes distribuïts són crítiques: si és segur (no modifica estat) i si és idempotent (repetir-lo N vegades té el mateix efecte que fer-ho una sola vegada).

Verb Significat Segur Idempotent Ús a TechCorp
GET Llegir una representació Sí Sí GET /comandes/{id}, GET /productes?ids=
POST Crear un recurs subordinat o executar un procés No No POST /comandes, POST /reserves
PUT Substituir el recurs complet en aquella URI No Sí PUT /clients/{id}/adreca (substituir l'adreça)
PATCH Modificar parcialment No Depèn del cos PATCH /productes/{id} amb {"preu": 54.90}
DELETE Eliminar No Sí DELETE /reserves/{id} (alliberar una reserva)

Per què importa la idempotència: a 01-02 vam veure que la xarxa no és fiable. Si Comandes crida PUT /clients/c-1024/adreca i la resposta es perd, pot repetir la crida sense por: l'adreça quedarà igual. Si repeteix un POST /reserves que ja s'havia processat, hi haurà dues reserves i l'estoc de p-501 baixarà dues vegades. Per això, a 02-05 vam introduir la capçalera Idempotency-Key per a POST /comandes: converteix un POST en repetible sense canviar-ne la semàntica. L'aplicarem igual a POST /reserves.

Sobre PATCH: {"preu": 54.90} és idempotent (repetir-lo deixa el mateix preu); {"incrementarEstoc": 5} no ho és. Regla de TechCorp: els PATCH descriuen l'estat desitjat, mai deltes.

  1. Codis d'estat que farem servir

No cal memoritzar els ~60 codis HTTP. TechCorp fa servir un subconjunt tancat, i tots els serveis l'apliquen igual (l'empaquetarem a @techcorp/comu-http, la llibreria tècnica de 02-02):

Codi Nom Quan el retornem Exemple a TechCorp
200 OK Èxit amb cos Lectures i modificacions que retornen el recurs GET /comandes/com-88213
201 Created Recurs creat i ja disponible Creació síncrona completa; porta Location POST /reserves (la reserva existeix en respondre)
202 Accepted Petició acceptada, procés en curs Creació que dispara una saga; porta Location POST /comandes (decisió de 02-05)
204 No Content Èxit sense cos DELETE, alguns PUT DELETE /reserves/res-4471
400 Bad Request Petició mal formada JSON invàlid, camp obligatori absent, tipus incorrecte POST /comandes sense linies
401 Unauthorized No autenticat Falta el token JWT o és invàlid (07-01) Qualsevol ruta protegida
403 Forbidden Autenticat però sense permís Client que intenta llegir la comanda d'un altre GET /comandes/com-99000 d'un altre
404 Not Found El recurs no existeix Id inexistent GET /clients/c-9999
409 Conflict Conflicte amb l'estat actual Sense estoc, versió obsoleta (If-Match), transició d'estat invàlida POST /reserves sense estoc; cancel·lar una comanda ja CONFIRMADA
422 Unprocessable Entity Sintaxi correcta, semàntica no Quantitat negativa, codi postal que no existeix POST /comandes amb quantitat: -1
429 Too Many Requests Límit de peticions superat Rate limiting al gateway (03-04) 1.000 peticions/min des d'una IP
500 Internal Server Error Fallada no controlada del servidor Excepció no capturada, BD caiguda sense gestionar Mai a propòsit
503 Service Unavailable Servei no disponible temporalment Arrencada, dependència caiguda, manteniment; pot portar Retry-After Comandes quan Catàleg no respon

Dos matisos que TechCorp fixa per conveni perquè tots els equips responguin igual:

  • 400 davant de 422. 400 és "no entenc la teva petició" (no és JSON, falta un camp, un número ve com a text). 422 és "l'entenc però no té sentit" (quantitat 0, data de lliurament en el passat). La distinció ajuda el client a saber si l'error és de serialització o de negoci.
  • 404 davant de 409 en errors de negoci. Del monòlit heretem quatre errors: CLIENT_NO_EXISTEIX → 404, PRODUCTE_NO_DISPONIBLE → 400, SENSE_ESTOC → 409, PAGAMENT_REBUTJAT → 402. A l'arquitectura nova POST /comandes respon 202 abans de saber si hi ha estoc o si el pagament passa, de manera que SENSE_ESTOC i PAGAMENT_REBUTJAT deixen de ser respostes HTTP d'aquell endpoint i passen a ser motius de comanda.cancellada. SENSE_ESTOC continua sent un 409 de POST /reserves (Inventari). PRODUCTE_NO_DISPONIBLE passa a 422 (la petició és correcta, però demana una cosa que no està a la venda).

  1. Els contractes REST clau de TechCorp

5.1 POST /comandes (Comandes, port 3002)

És el contracte més important del curs. Petició: el client envia només el que sap: què vol i on ho vol. No envia noms ni preus de producte (els congela Comandes consultant Catàleg, com fixa l'agregat de 02-03), ni el total (el calcula Comandes).

POST /comandes HTTP/1.1
Host: servei-comandes:3002
Content-Type: application/json
Accept: application/json
Idempotency-Key: 7f3c9a2e-1b4d-4e8f-9c21-5a6b7c8d9e0f
X-Request-Id: req-01J4ZK9X2M

{
  "clientId": "c-1024",
  "linies": [
    { "producteId": "p-501", "quantitat": 1 },
    { "producteId": "p-777", "quantitat": 2 }
  ],
  "adrecaEnviament": {
    "carrer": "Gran Vía 12",
    "codiPostal": "28013",
    "ciutat": "Madrid",
    "pais": "ES"
  }
}

Resposta: 202 Accepted perquè, com vam decidir a 02-05, la saga (reserva d'estoc, cobrament, confirmació) continua en curs. Location diu on consultar el progrés. El cos retorna la comanda tal com ha quedat desada, amb els preus ja congelats i l'estat PENDENT.

HTTP/1.1 202 Accepted
Content-Type: application/json
Location: /comandes/com-88213
X-Request-Id: req-01J4ZK9X2M

{
  "id": "com-88213",
  "estat": "PENDENT",
  "clientId": "c-1024",
  "linies": [
    { "producteId": "p-501", "nom": "Auriculars BT X200", "quantitat": 1, "preuUnitari": 59.90 },
    { "producteId": "p-777", "nom": "Cable USB-C 2 m", "quantitat": 2, "preuUnitari": 9.90 }
  ],
  "total": 79.70,
  "adrecaEnviament": { "carrer": "Gran Vía 12", "codiPostal": "28013", "ciutat": "Madrid", "pais": "ES" },
  "creatEn": "2026-08-15T10:32:07Z",
  "_links": {
    "self": { "href": "/comandes/com-88213" },
    "cancellar": { "href": "/comandes/com-88213/cancellacio", "method": "POST" }
  }
}

Comportament amb Idempotency-Key: si el mateix client repeteix la petició amb la mateixa clau (perquè n'ha perdut la resposta), Comandes consulta claus_idempotencia (02-05) i retorna exactament la mateixa resposta, sense crear cap altra comanda. Si la clau es reutilitza amb un cos diferent, respon 422 amb codi CLAU_IDEMPOTENCIA_REUTILITZADA.

Errors possibles d'aquest endpoint (tots en el format de l'apartat 6):

Situació Codi codi
Falta linies o clientId, JSON invàlid 400 PETICIO_INVALIDA
quantitat ≤ 0, linies buit, país no suportat 422 DADES_NO_VALIDES
El client no existeix (Comandes ho comprova a clients_ref o cridant Clients) 404 CLIENT_NO_EXISTEIX
Algun producte no està a la venda segons Catàleg 422 PRODUCTE_NO_DISPONIBLE
Catàleg no respon a temps 503 DEPENDENCIA_NO_DISPONIBLE

5.2 GET /comandes/{id}

És la lectura que segueix el 202: la web fa polling (o rep una notificació) fins que l'estat deixa de ser PENDENT.

GET /comandes/com-88213 HTTP/1.1
Host: servei-comandes:3002
Accept: application/json
If-None-Match: "v3"
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "v4"

{
  "id": "com-88213",
  "estat": "CONFIRMADA",
  "clientId": "c-1024",
  "linies": [ "..." ],
  "total": 79.70,
  "historial": [
    { "estat": "PENDENT", "en": "2026-08-15T10:32:07Z" },
    { "estat": "ESTOC_RESERVAT", "en": "2026-08-15T10:32:08Z" },
    { "estat": "PAGADA", "en": "2026-08-15T10:32:10Z" },
    { "estat": "CONFIRMADA", "en": "2026-08-15T10:32:10Z" }
  ],
  "_links": { "self": { "href": "/comandes/com-88213" } }
}

Si la comanda no ha canviat des de la versió "v3", el servidor respon 304 Not Modified sense cos (estalvia amplada de banda en el polling). Si la comanda és d'un altre client, 403; si no existeix, 404.

5.3 GET /productes?ids=p-501,p-777 (Catàleg, port 3001)

És la crida síncrona interna més freqüent: Comandes necessita nom, preu i disponibilitat de cada línia per congelar-los. Dissenyar GET /productes/{id} i cridar-lo un cop per línia seria el clàssic problema N+1: una comanda de 20 línies serien 20 viatges de xarxa. Per això el contracte accepta un lot:

GET /productes?ids=p-501,p-777 HTTP/1.1
Host: servei-cataleg:3001
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=30

{
  "dades": [
    { "id": "p-501", "nom": "Auriculars BT X200", "preu": 59.90, "moneda": "EUR", "disponible": true },
    { "id": "p-777", "nom": "Cable USB-C 2 m", "preu": 9.90, "moneda": "EUR", "disponible": true }
  ],
  "noTrobats": []
}

Decisions de contracte que convé explicar:

  • Els ids que no existeixen no provoquen 404. Un lot és una consulta a la col·lecció; que en falti un dels ids no fa fallar la petició. Es retornen a noTrobats i és Comandes qui decideix què fer-ne (respondre 422 PRODUCTE_NO_DISPONIBLE).
  • Límit de lot. Màxim 100 ids; per sobre, 400. Sense límit, algú acabaria demanant 5.000 productes en una URL.
  • Cache-Control: max-age=30. El catàleg canvia poc; permetre 30 s de memòria cau al client redueix càrrega als pics ×20.
  • Aquest JSON és el published language de 02-03; Comandes el tradueix amb el seu traductorProducte al seu propi model, de manera que si Catàleg canvia preu (ho veurem a 03-06), només canvia el traductor.

5.4 GET /clients/{id} (Clients, port 3004)

Comandes acostuma a resoldre el client a la seva rèplica local clients_ref (02-04), però quan la rèplica encara no el té (client acabat de registrar) fa aquesta crida:

GET /clients/c-1024 HTTP/1.1
Host: servei-clients:3004
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "c-1024:7"

{
  "id": "c-1024",
  "nom": "Ana Ruiz",
  "email": "[email protected]",
  "adreces": [
    { "id": "adr-1", "carrer": "Gran Vía 12", "codiPostal": "28013", "ciutat": "Madrid", "pais": "ES", "predeterminada": true }
  ]
}

Fixa't en el que no retorna: contrasenya, tokens, dades de pagament. La representació és una vista de negoci del client, no un bolcat de la taula.

5.5 POST /reserves (Inventari, port 3006)

A la saga per coreografia de 02-05, Inventari reserva estoc reaccionant a l'esdeveniment comanda.creada, no per una crida HTTP. Per a què serveix, doncs, un POST /reserves? Per tres motius: per al panell d'administració intern, per a proves, i perquè a 02-03 vam deixar oberta una relació partnership Comandes↔Inventari que en el futur podria passar a síncrona (gRPC a 03-03). Dissenyar el contracte ara costa poc i fixa el vocabulari.

POST /reserves HTTP/1.1
Host: servei-inventari:3006
Content-Type: application/json
Idempotency-Key: com-88213

{
  "comandaId": "com-88213",
  "linies": [
    { "producteId": "p-501", "quantitat": 1 },
    { "producteId": "p-777", "quantitat": 2 }
  ],
  "expiraEnSegons": 900
}

Resposta quan hi ha estoc. Aquí sí que és 201, perquè la reserva queda feta en el mateix instant (una transacció local a la BD d'Inventari):

HTTP/1.1 201 Created
Content-Type: application/json
Location: /reserves/res-4471

{
  "id": "res-4471",
  "comandaId": "com-88213",
  "estat": "ACTIVA",
  "expiraEn": "2026-08-15T10:47:07Z",
  "linies": [
    { "producteId": "p-501", "quantitat": 1 },
    { "producteId": "p-777", "quantitat": 2 }
  ]
}

I quan no hi ha estoc, un 409 amb el detall de què falta (informació que la saga converteix en estoc.rebutjat):

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://techcorp.example/errors/sense-estoc",
  "title": "Estoc insuficient",
  "status": 409,
  "detail": "No hi ha prou unitats d'1 producte",
  "codi": "SENSE_ESTOC",
  "instance": "/reserves",
  "faltants": [ { "producteId": "p-501", "sollicitat": 1, "disponible": 0 } ]
}

Fer servir comandaId com a Idempotency-Key és deliberat: "una reserva per comanda" és exactament la garantia que volem.

  1. Format d'errors uniforme: RFC 7807

Al monòlit, cada controlador retornava els errors com li semblava: de vegades {"error": "..."}, de vegades {"missatge": "..."}, de vegades HTML. Amb sis serveis i quatre equips, això es multiplica. L'RFC 7807 (Problem Details for HTTP APIs) defineix un format estàndard amb tipus MIME application/problem+json:

Camp Obligatori Significat
type Sí (per defecte about:blank) URI que identifica el tipus de problema. No cal que resolgui a res, però és útil que apunti a documentació.
title Sí Resum llegible, igual per a tots els errors del mateix tipus.
status Sí El codi HTTP, repetit al cos (útil quan el cos es registra o es reenvia sense les capçaleres).
detail No Explicació específica d'aquesta ocurrència.
instance No URI de la petició concreta que ha fallat.
extensions No Qualsevol camp addicional. TechCorp hi afegeix sempre codi.

El camp codi és l'extensió que TechCorp estandarditza: un identificador estable en MAJUSCULES_AMB_GUIONS (CLIENT_NO_EXISTEIX, SENSE_ESTOC, PRODUCTE_NO_DISPONIBLE, PETICIO_INVALIDA, DADES_NO_VALIDES, DEPENDENCIA_NO_DISPONIBLE, VERSIO_OBSOLETA, CLAU_IDEMPOTENCIA_REUTILITZADA). Els clients fan switch sobre codi, mai sobre detail, que és text per a humans i pot canviar.

Exemple d'error de validació amb detall per camp (extensió errors):

{
  "type": "https://techcorp.example/errors/dades-no-valides",
  "title": "Les dades de la petició no són vàlides",
  "status": 422,
  "detail": "1 camp no supera la validació",
  "codi": "DADES_NO_VALIDES",
  "instance": "/comandes",
  "errors": [
    { "camp": "linies[1].quantitat", "missatge": "ha de ser més gran que 0" }
  ]
}

Mapatge dels errors heretats del monòlit al model nou:

Error del monòlit Codi HTTP abans Ara: on apareix Codi HTTP ara
CLIENT_NO_EXISTEIX 404 POST /comandes (validació prèvia) 404
PRODUCTE_NO_DISPONIBLE 400 POST /comandes després de consultar Catàleg 422
SENSE_ESTOC 409 POST /reserves (Inventari); a la saga, motiu de comanda.cancellada 409
PAGAMENT_REBUTJAT 402 Ja no és una resposta HTTP: és l'esdeveniment pagament.rebutjat i el motiu PAGAMENT_REBUTJAT de comanda.cancellada —

Un consell de seguretat: en un 500 el detail mai no inclou el missatge de l'excepció ni la traça (filtraria rutes, consultes SQL, noms de host). Es registra al log amb l'X-Request-Id i el client rep un detail genèric amb aquell identificador per poder-ho reportar.

  1. Paginació, filtratge i ordenació

Tota col·lecció que pugui créixer es pagina; GET /comandes sense límit acabaria retornant 3.000 comandes diàries acumulades durant anys.

Estratègia Petició Avantatges Inconvenients Ús a TechCorp
Offset GET /productes?limit=20&desplacament=40 Senzilla; permet saltar a la pàgina N; fàcil en SQL (LIMIT 20 OFFSET 40) Lenta amb offsets grans; si s'insereix un element entre dues pàgines, se'n repeteix o se'n salta un Panell d'administració del catàleg (col·leccions petites, cal "anar a la pàgina 7")
Cursor GET /comandes?limit=20&cursor=eyJjcmVhdEVuIjouLi59 Estable davant d'insercions; rendiment constant (WHERE (creat_en, id) < (...)) No permet saltar a una pàgina arbitrària; el cursor és opac Historial de comandes del client, llistats d'esdeveniments: col·leccions grans que creixen per un extrem

Resposta paginada per cursor. El cursor és una cadena opaca (habitualment base64 de {creatEn, id} de l'últim element) que el client retorna tal qual:

GET /comandes?clientId=c-1024&limit=2&ordre=-creatEn HTTP/1.1
{
  "dades": [
    { "id": "com-88213", "estat": "CONFIRMADA", "total": 79.70, "creatEn": "2026-08-15T10:32:07Z" },
    { "id": "com-88102", "estat": "CONFIRMADA", "total": 24.50, "creatEn": "2026-08-14T18:05:44Z" }
  ],
  "paginacio": {
    "limit": 2,
    "seguentCursor": "eyJjcmVhdEVuIjoiMjAyNi0wOC0xNFQxODowNTo0NFoiLCJpZCI6ImNvbS04ODEwMiJ9",
    "hiHaSeguent": true
  },
  "_links": {
    "self": { "href": "/comandes?clientId=c-1024&limit=2&ordre=-creatEn" },
    "seguent": { "href": "/comandes?clientId=c-1024&limit=2&ordre=-creatEn&cursor=eyJjcmVhdEVuIjo..." }
  }
}

Convenis de TechCorp per al filtratge i l'ordenació:

  • Filtres com a paràmetres de query amb el nom del camp: ?estat=CONFIRMADA, ?clientId=c-1024. Rangs amb sufixos: ?creatDes=2026-08-01&creatFins=2026-08-15.
  • Ordenació amb ordre=camp ascendent i ordre=-camp descendent; diversos camps separats per coma: ordre=-creatEn,id.
  • limit amb valor per defecte (20) i màxim (100). Un client que en demana 10.000 rep 400.
  • Només s'admeten els filtres i ordres documentats a OpenAPI; un paràmetre desconegut s'ignora (tolerància, que 03-06 formalitzarà), però un valor invàlid en un de conegut dona 400.

  1. HATEOAS a nivell pràctic

HATEOAS (Hypermedia As The Engine Of Application State) és la restricció de REST que diu que la resposta ha d'incloure enllaços a les accions possibles, de manera que el client "navegui" per l'API en lloc de construir URIs. Portat a l'extrem produeix APIs difícils de consumir i pocs l'apliquen del tot. TechCorp n'adopta una versió pragmàtica:

  • Un objecte _links amb self sempre.
  • Enllaços a accions que depenen de l'estat: una comanda PENDENT inclou cancellar; una de CONFIRMADA inclou factura però no cancellar. Així el front-end no duplica la màquina d'estats de 02-05 per saber quin botó ha de mostrar.
  • Enllaços de paginació (seguent, anterior).
  • Res més. No s'enllacen recursos d'altres serveis (una comanda no enllaça al /clients/{id} de Clients), perquè el client entra sempre pel gateway i les URIs internes no li serveixen.
{
  "id": "com-88213",
  "estat": "PENDENT",
  "_links": {
    "self": { "href": "/comandes/com-88213" },
    "cancellar": { "href": "/comandes/com-88213/cancellacio", "method": "POST" }
  }
}

  1. Capçaleres útils

Capçalera Direcció Per a què la fem servir
Content-Type: application/json Petició i resposta Format del cos. Els errors fan servir application/problem+json.
Accept: application/json Petició Format desitjat. Si el servidor no pot, 406. A 03-06 servirà també per versionar.
Location Resposta 201/202 URI del recurs creat o d'on consultar el progrés.
ETag / If-None-Match Resposta / petició GET Memòria cau condicional: 304 si no ha canviat.
ETag / If-Match Resposta / petició PUT/PATCH Concurrència optimista: el client diu "modifica només si continua a la versió que jo he vist"; si no, 412 Precondition Failed (o 409 amb VERSIO_OBSOLETA, el conveni de TechCorp per uniformar).
Idempotency-Key Petició POST Repetir sense duplicar (02-05). UUID generat pel client.
X-Request-Id Totes dues Identificador de correlació: el genera el gateway si no ve, cada servei el propaga a les seves crides i als seus logs. És la llavor de la traçabilitat distribuïda que es veu a 06-02.
Cache-Control Resposta max-age en lectures que es poden desar en memòria cau (catàleg); no-store en comandes i dades personals.
Retry-After Resposta 429/503 Quants segons cal esperar abans de reintentar.

Exemple de concurrència optimista: dos administradors editen el preu de p-501 alhora.

PATCH /productes/p-501 HTTP/1.1
Content-Type: application/json
If-Match: "p-501:12"

{ "preu": 54.90 }

Si el producte ja és a la versió 13 perquè l'altre administrador ha desat abans, Catàleg respon 409 amb codi: VERSIO_OBSOLETA i el client recarrega i decideix. Sense If-Match, el segon desament trepitjaria el primer en silenci.

  1. Documentació amb OpenAPI 3

Un contracte que només viu al cap del Luis no és un contracte. OpenAPI 3 és l'especificació estàndard (YAML o JSON) per descriure APIs REST: rutes, paràmetres, cossos, respostes i esquemes. A partir d'ella es generen documentació navegable (Swagger UI, Redoc), clients, validadors de peticions i, a 04-05, proves. Fragment de l'especificació de Comandes i Catàleg:

openapi: 3.0.3
info:
  title: TechCorp - API de Comandes
  version: 1.0.0
servers:
  - url: http://servei-comandes:3002
paths:
  /comandes:
    post:
      summary: Crear una comanda
      operationId: crearComanda
      parameters:
        - in: header
          name: Idempotency-Key
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NovaComanda' }
      responses:
        '202':
          description: Comanda acceptada; la saga continua en curs
          headers:
            Location:
              schema: { type: string, example: /comandes/com-88213 }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Comanda' }
        '404':
          $ref: '#/components/responses/ClientNoExisteix'
        '422':
          $ref: '#/components/responses/DadesNoValides'
components:
  schemas:
    NovaComanda:
      type: object
      required: [clientId, linies, adrecaEnviament]
      properties:
        clientId: { type: string, example: c-1024 }
        linies:
          type: array
          minItems: 1
          items:
            type: object
            required: [producteId, quantitat]
            properties:
              producteId: { type: string, example: p-501 }
              quantitat: { type: integer, minimum: 1 }
        adrecaEnviament: { $ref: '#/components/schemas/Adreca' }
    Comanda:
      type: object
      properties:
        id: { type: string, example: com-88213 }
        estat:
          type: string
          enum: [PENDENT, ESTOC_RESERVAT, PAGADA, CONFIRMADA, CANCELLADA]
        total: { type: number, format: double, example: 79.70 }
    Problema:
      type: object
      required: [type, title, status, codi]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        codi: { type: string, example: CLIENT_NO_EXISTEIX }
  responses:
    ClientNoExisteix:
      description: El client indicat no existeix
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problema' }
    DadesNoValides:
      description: Les dades no superen la validació de negoci
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problema' }

I el lot de Catàleg, al seu propi document (cada servei publica el seu OpenAPI; no hi ha cap fitxer global):

paths:
  /productes:
    get:
      summary: Obtenir productes per lot d'ids
      operationId: obtenirProductes
      parameters:
        - in: query
          name: ids
          required: true
          description: Ids separats per coma (màxim 100)
          schema: { type: string, example: "p-501,p-777" }
      responses:
        '200':
          description: Productes trobats; els ids inexistents van a noTrobats
          content:
            application/json:
              schema:
                type: object
                properties:
                  dades:
                    type: array
                    items: { $ref: '#/components/schemas/Producte' }
                  noTrobats:
                    type: array
                    items: { type: string }

Com es llegeix: paths agrupa rutes; dins de cada verb, parameters descriu query/capçaleres/ruta, requestBody el cos, i responses cada codi amb el seu esquema. components evita repetir esquemes i respostes. A 03-06 veurem que escriure aquest YAML abans del codi (design-first) és la manera que Comandes i Catàleg treballin en paral·lel.

  1. Un endpoint Express i un client fetch que apliquen el contracte

Encara no muntem el servei complet (estructura, configuració i arrencada són del mòdul 4); només el tros que materialitza el contracte de POST /comandes.

// rutes/comandes.js (servei-comandes) — només la ruta, sense la resta del servei
const express = require('express');
const { randomUUID } = require('node:crypto');
const enrutador = express.Router();

// 1. Validació de forma (400) i de negoci bàsica (422)
function validarNovaComanda(cos) {
  if (!cos || typeof cos.clientId !== 'string' || !Array.isArray(cos.linies)) {
    return { status: 400, codi: 'PETICIO_INVALIDA', detail: 'Falten clientId o linies' };
  }
  const errors = [];
  if (cos.linies.length === 0) errors.push({ camp: 'linies', missatge: 'ha de tenir com a mínim una línia' });
  cos.linies.forEach((l, i) => {
    if (!Number.isInteger(l.quantitat) || l.quantitat < 1) {
      errors.push({ camp: `linies[${i}].quantitat`, missatge: 'ha de ser un enter més gran que 0' });
    }
  });
  if (errors.length > 0) {
    return { status: 422, codi: 'DADES_NO_VALIDES', detail: `${errors.length} camp(s) no vàlid(s)`, errors };
  }
  return null;
}

// 2. Helper per respondre errors en format RFC 7807
function respondreProblema(res, req, problema) {
  res.status(problema.status)
     .type('application/problem+json')
     .json({
       type: `https://techcorp.example/errors/${problema.codi.toLowerCase().replace(/_/g, '-')}`,
       title: problema.title ?? 'Error a la petició',
       status: problema.status,
       detail: problema.detail,
       codi: problema.codi,
       instance: req.originalUrl,
       ...(problema.errors && { errors: problema.errors })
     });
}

// 3. La ruta: valida, delega en el cas d'ús, respon 202 + Location
enrutador.post('/comandes', async (req, res, next) => {
  const clauIdempotencia = req.get('Idempotency-Key');
  if (!clauIdempotencia) {
    return respondreProblema(res, req, { status: 400, codi: 'PETICIO_INVALIDA', detail: 'Falta la capçalera Idempotency-Key' });
  }
  const problema = validarNovaComanda(req.body);
  if (problema) return respondreProblema(res, req, problema);

  try {
    // crearComanda consulta Catàleg, congela preus, desa comanda + esdeveniment a l'outbox (02-05)
    // i retorna la comanda en estat PENDENT. La seva implementació completa és del mòdul 4.
    const comanda = await req.app.locals.casosDUs.crearComanda(req.body, {
      clauIdempotencia,
      requestId: req.get('X-Request-Id') ?? randomUUID()
    });
    res.status(202)
       .location(`/comandes/${comanda.id}`)
       .json({
         ...comanda,
         _links: {
           self: { href: `/comandes/${comanda.id}` },
           cancellar: { href: `/comandes/${comanda.id}/cancellacio`, method: 'POST' }
         }
       });
  } catch (err) {
    // Errors de negoci coneguts → codis del contracte; la resta → 500 (middleware d'errors)
    if (err.codi === 'CLIENT_NO_EXISTEIX') return respondreProblema(res, req, { status: 404, codi: err.codi, detail: err.message });
    if (err.codi === 'PRODUCTE_NO_DISPONIBLE') return respondreProblema(res, req, { status: 422, codi: err.codi, detail: err.message });
    if (err.codi === 'DEPENDENCIA_NO_DISPONIBLE') return respondreProblema(res, req, { status: 503, codi: err.codi, detail: err.message });
    next(err);
  }
});

module.exports = enrutador;

Explicació pas a pas:

  1. validarNovaComanda separa els dos nivells: si la forma és incorrecta retorna 400; si la forma és correcta però els valors no, 422 amb la llista de camps. És exactament la distinció de l'apartat 4.
  2. respondreProblema centralitza el format RFC 7807. Al projecte real aquesta funció viu a @techcorp/comu-http perquè Catàleg, Inventari i la resta responguin idèntic.
  3. La ruta comprova Idempotency-Key, valida, delega en el cas d'ús (que ja coneixem de 02-05: desa la comanda i el seu comanda.creada a la mateixa transacció amb desarAmbEsdeveniments()), i respon 202 amb Location. Fixa't que la ruta no sap res de RabbitMQ ni d'estoc: només tradueix HTTP a negoci i negoci a HTTP.
  4. Els errors de negoci es mapen a codis del contracte; qualsevol altre va al middleware d'errors d'Express, que respon 500 sense filtrar detalls.

I el client: com Comandes crida el lot de Catàleg. Node.js 20 inclou fetch de manera nativa, i AbortSignal.timeout és la manera mínima de no quedar-se esperant per sempre.

// clients/catalegClient.js (dins de servei-comandes)
const CATALEG_URL = process.env.CATALEG_URL ?? 'http://servei-cataleg:3001';

async function obtenirProductes(ids, { requestId }) {
  const url = `${CATALEG_URL}/productes?ids=${encodeURIComponent(ids.join(','))}`;

  let resposta;
  try {
    resposta = await fetch(url, {
      headers: { 'Accept': 'application/json', 'X-Request-Id': requestId },
      // Si Catàleg no respon en 2 s, fetch llança un error de tipus TimeoutError
      signal: AbortSignal.timeout(2000)
    });
  } catch (err) {
    // Timeout o error de xarxa: per al contracte de POST /comandes això és un 503
    const error = new Error(`Catàleg no disponible: ${err.name}`);
    error.codi = 'DEPENDENCIA_NO_DISPONIBLE';
    throw error;
  }

  if (!resposta.ok) {
    // Catàleg ha respost, però amb error: llegim el problem+json per registrar el codi
    const problema = await resposta.json().catch(() => ({}));
    const error = new Error(`Catàleg ha respost ${resposta.status} (${problema.codi ?? 'sense codi'})`);
    error.codi = resposta.status >= 500 ? 'DEPENDENCIA_NO_DISPONIBLE' : 'PETICIO_INVALIDA';
    throw error;
  }

  const { dades, noTrobats } = await resposta.json();
  if (noTrobats.length > 0) {
    const error = new Error(`Productes no disponibles: ${noTrobats.join(', ')}`);
    error.codi = 'PRODUCTE_NO_DISPONIBLE';
    throw error;
  }
  return dades; // el traductorProducte (ACL de 02-03) els convertirà al model de Comandes
}

module.exports = { obtenirProductes };

Punts clau del client: la URL base ve de configuració (CATALEG_URL, amb un valor per defecte que és el nom estable del servei que justificarem a 03-05 i gestionarem a 04-03); es propaga X-Request-Id; hi ha un timeout de 2 s (sense ell, una caiguda de Catàleg esgotaria les connexions de Comandes); es distingeix "no ha respost" de "ha respost amb error"; i noTrobats es tradueix a l'error de negoci del contracte de POST /comandes. El que no hi ha aquí (reintents, circuit breaker, fallback) és matèria de 06-03.

Errors Comuns i Consells

  • Verbs a les URIs (/comandes/crear, /productes/cercar). Si t'enxampes escrivint un verb, pregunta't quin és el recurs: gairebé sempre és una col·lecció (POST /comandes) o un subrecurs (POST /comandes/{id}/cancellacio).
  • Retornar 200 per a tot i posar l'error al cos ({"ok": false}). Trenca la memòria cau HTTP, els monitors del gateway i la intuïció de qualsevol client. Fes servir la taula de l'apartat 4.
  • 201 quan la feina no ha acabat. Si en respondre encara no saps si hi haurà estoc, és 202. Un 201 promet que el recurs és complet.
  • N+1 per comoditat. GET /productes/{id} dins d'un bucle sembla net fins que una comanda de 15 línies triga 15 × 40 ms. Dissenya lots (?ids=) des del principi.
  • Idempotency-Key sense persistència. Desar les claus en memòria del procés no serveix amb dues rèpliques: la taula claus_idempotencia de 02-05 és obligatòria.
  • Missatges d'error com a contracte. Els clients han de mirar codi, no detail. Canviar un text no hauria de trencar ningú.
  • Filtrar interns en errors 500. Mai detail: err.stack. Log complet amb X-Request-Id; al client, un missatge genèric amb aquell id.
  • Oblidar el timeout al client. fetch sense signal espera indefinidament. Dos segons per a crides internes és un bon punt de partida.
  • Documentar després. L'OpenAPI escrit a posteriori es desactualitza. Escriu-lo primer (03-06) i valida les peticions contra ell.

Exercicis

Exercici 1. Dissenya el contracte REST de "cancel·lar una comanda" per al servei de Comandes: URI i verb, cos de petició (amb motiu), respostes d'èxit i d'error amb els seus codis i codi, tenint en compte la màquina d'estats de 02-05 (només es pot cancel·lar en PENDENT, ESTOC_RESERVAT o PAGADA; no en CONFIRMADA ni si ja està CANCELLADA). Escriu un exemple http de petició i d'una resposta d'error.

Exercici 2. L'equip d'Experiència de compra proposa GET /clients/c-1024/comandes (al servei de Clients) perquè la web mostri l'historial de comandes d'un client. Raona si aquest endpoint ha de viure a Clients o a Comandes, quina URI proposaries, i quina estratègia de paginació (offset o cursor) triaries i per què. Escriu la resposta JSON de la primera pàgina amb dues comandes.

Exercici 3. Escriu una funció Express patch('/productes/:id') per a Catàleg que apliqui concurrència optimista amb If-Match. Suposa que existeix repositori.obtenir(id) que retorna { ...producte, versio } i repositori.actualitzarSiVersio(id, canvis, versioEsperada) que retorna el producte actualitzat o null si la versió no coincideix. Respon 428 Precondition Required si falta If-Match, 404 si no existeix, 409 VERSIO_OBSOLETA si la versió no coincideix i 200 amb un ETag nou si tot va bé.

Solucions

Solució 1.

Contracte: POST /comandes/{id}/cancellacio. La cancel·lació és un fet de negoci amb dades pròpies (motiu, qui cancel·la), no l'edició d'un camp, i POST és el verb per a "crear un subrecurs". Ha d'acceptar Idempotency-Key (o, més senzill, ser idempotent per disseny: cancel·lar una comanda ja cancel·lada pel mateix motiu retorna 200 amb el mateix cos).

POST /comandes/com-88213/cancellacio HTTP/1.1
Content-Type: application/json
Idempotency-Key: 2c1e5f9a-8b3d-4a7c-9e6f-1d2c3b4a5f60

{ "motiu": "CLIENT_ES_PENEDEIX", "comentari": "Comanda duplicada per error" }

Respostes:

Situació Codi codi
Cancel·lació acceptada (dispara comanda.cancellada; si hi havia reserva o pagament, la saga els compensa) 202 (la compensació és asíncrona; l'estat de la comanda passa a CANCELLADA immediatament però l'alliberament d'estoc i el reemborsament continuen en curs) amb Location: /comandes/com-88213 —
La comanda no existeix 404 COMANDA_NO_EXISTEIX
Comanda d'un altre client 403 ACCES_DENEGAT
Comanda en CONFIRMADA 409 TRANSICIO_NO_PERMESA
Falta motiu 400 PETICIO_INVALIDA
motiu no és a la llista permesa 422 DADES_NO_VALIDES
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://techcorp.example/errors/transicio-no-permesa",
  "title": "La transició d'estat no està permesa",
  "status": 409,
  "detail": "La comanda com-88213 està CONFIRMADA i ja no admet cancel·lació",
  "codi": "TRANSICIO_NO_PERMESA",
  "instance": "/comandes/com-88213/cancellacio",
  "estatActual": "CONFIRMADA"
}

Es pot argumentar 200 en lloc de 202 si es considera que la comanda queda CANCELLADA de manera síncrona i les compensacions són un detall intern; totes dues opcions són defensables, però cal documentar la triada a OpenAPI i aplicar-la igual a tota l'API.

Solució 2.

Les comandes són del bounded context de Comandes (02-03): l'historial l'ha de servir el servei de Comandes, que és qui té les dades i el seu estat actualitzat. Posar l'endpoint a Clients obligaria Clients a consultar Comandes (acoblament innecessari) o a replicar comandes (que no li pertanyen). URI proposada: GET /comandes?clientId=c-1024&ordre=-creatEn&limit=20 a Comandes. La ruta imbricada /clients/{id}/comandes la pot oferir el gateway o el BFF (03-04) com a comoditat per al front, redirigint-la internament a Comandes, però el contracte canònic és el filtre sobre la col·lecció /comandes.

Paginació: cursor. L'historial d'un client creix per un extrem (les comandes noves entren a dalt), és una col·lecció potencialment gran i el cas d'ús és "scroll infinit" a la web i a l'app, no "anar a la pàgina 7". Amb offset, una comanda nova creada mentre l'usuari navega ho desplaçaria tot i repetiria un element entre pàgines.

{
  "dades": [
    { "id": "com-88213", "estat": "CONFIRMADA", "total": 79.70, "creatEn": "2026-08-15T10:32:07Z", "_links": { "self": { "href": "/comandes/com-88213" } } },
    { "id": "com-88102", "estat": "CONFIRMADA", "total": 24.50, "creatEn": "2026-08-14T18:05:44Z", "_links": { "self": { "href": "/comandes/com-88102" } } }
  ],
  "paginacio": { "limit": 2, "seguentCursor": "eyJjcmVhdEVuIjoiMjAyNi0wOC0xNFQxODowNTo0NFoiLCJpZCI6ImNvbS04ODEwMiJ9", "hiHaSeguent": true },
  "_links": {
    "self": { "href": "/comandes?clientId=c-1024&ordre=-creatEn&limit=2" },
    "seguent": { "href": "/comandes?clientId=c-1024&ordre=-creatEn&limit=2&cursor=eyJjcmVhdEVuIjoiMjAyNi0wOC0xNFQxODowNTo0NFoiLCJpZCI6ImNvbS04ODEwMiJ9" }
  }
}

Solució 3.

enrutador.patch('/productes/:id', async (req, res, next) => {
  const ifMatch = req.get('If-Match');
  if (!ifMatch) {
    return respondreProblema(res, req, {
      status: 428, codi: 'PRECONDICIO_REQUERIDA',
      detail: 'Cal enviar If-Match amb la versió del producte que s\'està modificant'
    });
  }
  try {
    const actual = await repositori.obtenir(req.params.id);
    if (!actual) {
      return respondreProblema(res, req, { status: 404, codi: 'PRODUCTE_NO_EXISTEIX', detail: `No existeix ${req.params.id}` });
    }
    // L'ETag té la forma "p-501:12"; n'extraiem el número de versió d'entre les cometes
    const versioEsperada = Number(ifMatch.replace(/"/g, '').split(':')[1]);
    const actualitzat = await repositori.actualitzarSiVersio(req.params.id, req.body, versioEsperada);
    if (!actualitzat) {
      return respondreProblema(res, req, {
        status: 409, codi: 'VERSIO_OBSOLETA',
        detail: `El producte ${req.params.id} ha canviat; recarrega'l (versió actual ${actual.versio})`
      });
    }
    res.set('ETag', `"${actualitzat.id}:${actualitzat.versio}"`).status(200).json(actualitzat);
  } catch (err) {
    next(err);
  }
});

Punts que cal comprovar: l'ETag de resposta reflecteix la versió nova (13), de manera que un segon PATCH del mateix client pot encadenar-s'hi; i actualitzarSiVersio ha de fer la comprovació a la mateixa sentència SQL (UPDATE ... WHERE id = $1 AND versio = $2), no en dos passos, perquè la concurrència optimista sigui real.

Conclusió

Hem convertit les decisions de disseny del mòdul 2 en contractes REST concrets: URIs amb substantius i jerarquies, verbs amb la seva idempotència (i Idempotency-Key quan el verb no la té), una taula tancada de codis d'estat que tots els serveis apliquen igual, els cinc contractes clau de TechCorp (POST /comandes amb 202 + Location, GET /comandes/{id} amb ETag, GET /productes?ids= en lot per evitar l'N+1, GET /clients/{id}, POST /reserves amb 201/409), errors uniformes RFC 7807 amb el camp codi que substitueix els errors dispersos del monòlit, paginació per cursor per a col·leccions que creixen, HATEOAS mínim, capçaleres de memòria cau, concurrència optimista i correlació, OpenAPI 3 com a forma escrita del contracte, i una ruta Express i un client fetch amb timeout que l'apliquen.

REST cobreix les crides síncrones, però a 02-05 vam decidir que el cor del flux de comanda (reservar estoc, cobrar, confirmar, notificar) viatja per esdeveniments: comanda.creada, estoc.reservat, pagament.confirmat, comanda.confirmada, comanda.cancellada. Falta veure com es publiquen i es consumeixen de debò: què és un exchange i una cua a RabbitMQ, com s'encamina cada esdeveniment cap a qui li interessa, què passa quan un consumidor falla i com garantim que un esdeveniment no es perd ni es processa dues vegades. Això és la missatgeria asíncrona, la lliçó següent.

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