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
- REST en microserveis: què és i què no és
- Recursos i URIs
- Verbs HTTP, semàntica i idempotència
- Codis d'estat que farem servir
- Els contractes REST clau de TechCorp
- Format d'errors uniforme: RFC 7807
- Paginació, filtratge i ordenació
- HATEOAS a nivell pràctic
- Capçaleres útils
- Documentació amb OpenAPI 3
- Un endpoint Express i un client
fetchque apliquen el contracte
- 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'inventaPOST /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.
- 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-88213amb{"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 ambmotiu, 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.
- 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.
- 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:
400davant de422.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" (quantitat0, data de lliurament en el passat). La distinció ajuda el client a saber si l'error és de serialització o de negoci.404davant de409en 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 novaPOST /comandesrespon202abans de saber si hi ha estoc o si el pagament passa, de manera queSENSE_ESTOCiPAGAMENT_REBUTJATdeixen de ser respostes HTTP d'aquell endpoint i passen a ser motius decomanda.cancellada.SENSE_ESTOCcontinua sent un409dePOST /reserves(Inventari).PRODUCTE_NO_DISPONIBLEpassa a422(la petició és correcta, però demana una cosa que no està a la venda).
- 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:
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 anoTrobatsi és Comandes qui decideix què fer-ne (respondre422 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
traductorProducteal seu propi model, de manera que si Catàleg canviapreu(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:
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.
- 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.
- 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:
{
"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=campascendent iordre=-campdescendent; diversos camps separats per coma:ordre=-creatEn,id. limitamb valor per defecte (20) i màxim (100). Un client que en demana 10.000 rep400.- 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.
- 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
_linksambselfsempre. - Enllaços a accions que depenen de l'estat: una comanda
PENDENTincloucancellar; una deCONFIRMADAincloufacturaperò nocancellar. 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" }
}
}
- 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.
- 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.
- Un endpoint Express i un client
fetch que apliquen el contracte
fetch que apliquen el contracteEncara 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:
validarNovaComandasepara els dos nivells: si la forma és incorrecta retorna400; si la forma és correcta però els valors no,422amb la llista de camps. És exactament la distinció de l'apartat 4.respondreProblemacentralitza el format RFC 7807. Al projecte real aquesta funció viu a@techcorp/comu-httpperquè Catàleg, Inventari i la resta responguin idèntic.- La ruta comprova
Idempotency-Key, valida, delega en el cas d'ús (que ja coneixem de 02-05: desa la comanda i el seucomanda.creadaa la mateixa transacció ambdesarAmbEsdeveniments()), i respon202ambLocation. Fixa't que la ruta no sap res de RabbitMQ ni d'estoc: només tradueix HTTP a negoci i negoci a HTTP. - Els errors de negoci es mapen a codis del contracte; qualsevol altre va al middleware d'errors d'Express, que respon
500sense 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
200per 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. 201quan la feina no ha acabat. Si en respondre encara no saps si hi haurà estoc, és202. Un201promet 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-Keysense persistència. Desar les claus en memòria del procés no serveix amb dues rèpliques: la taulaclaus_idempotenciade 02-05 és obligatòria.- Missatges d'error com a contracte. Els clients han de mirar
codi, nodetail. Canviar un text no hauria de trencar ningú. - Filtrar interns en errors
500. Maidetail: err.stack. Log complet ambX-Request-Id; al client, un missatge genèric amb aquell id. - Oblidar el timeout al client.
fetchsensesignalespera 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
- 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
