Durant cinc mòduls hem anat construint l'API de la Botiga Aroma per parts: primer els conceptes, després el disseny, després el codi, la seguretat, l'operació i les eines. Cada lliçó resolia un problema i en deixava un altre d'obert. Aquesta lliçó fa una cosa diferent: mira el resultat complet i pregunta per què és així.

No és un resum. Un resum repetiria el que ja saps; aquí el que interessa és la justificació de conjunt, que és el que mai no apareix quan aprens peça a peça. Per què la cistella va acabar sent un recurs i el procés de compra no. Per què les comandes es paginen per cursor i els cafès per desplaçament. Per què els diners viatgen en euros però es desen en cèntims. I, sobretot, què es va descartar a cada bifurcació i quin preu es va pagar per triar.

Pensa en aquesta lliçó com la memòria tècnica que lliuraries a un equip que hereta el projecte: context, decisions amb la seva alternativa descartada, fluxos complets, els casos difícils que gairebé ningú no documenta, l'arquitectura desplegada, els objectius de nivell de servei i una autocrítica honesta del que no va sortir bé. Al final tindràs una llista de verificació que pots aplicar a qualsevol API que dissenyis.

Contingut

  1. El negoci i les seves restriccions
  2. Els consumidors: cinc clients, cinc necessitats diferents
  3. Casos d'ús crítics i requisits no funcionals
  4. Del model de domini als recursos
  5. El mapa complet d'URIs
  6. Les decisions de disseny i les seves alternatives descartades
  7. El flux complet d'una compra
  8. Casos difícils i com es van resoldre
  9. L'arquitectura desplegada
  10. Mètriques i SLO del servei
  11. El que faríem diferent
  12. Llista de verificació final del projecte

  1. El negoci i les seves restriccions

La Botiga Aroma ven cafè d'especialitat. No és un supermercat: el catàleg té entre 40 i 120 referències vives, amb origen, torrefacció, notes de tast i lots que s'esgoten. El marge és alt, el volum és moderat i la fidelitat del client ho és tot: un comprador habitual demana cada tres o quatre setmanes durant anys.

Aquestes tres característiques del negoci condicionen l'API molt més del que sembla:

Tret del negoci Conseqüència tècnica
Catàleg petit i de canvi lent El catàleg és cacheable de manera agressiva; no necessita paginació per cursor ni cerca distribuïda
Estoc real i finit per lot L'estoc és consistent i transaccional, no aproximat; no podem vendre el que no hi ha
Poques comandes però d'alt valor Una comanda duplicada és un problema seriós: la idempotència no és cap luxe
Compra recurrent Les comandes creixen sense parar per client: paginació per cursor a /comandes
Campanya de Nadal Pics de 20-30 vegades el trànsit normal durant sis setmanes
Dades personals de clients europeus RGPD aplicable: dret d'accés, rectificació i supressió

Cap d'aquestes decisions no surt d'un llibre d'estil. Surten del negoci. Aquest és el primer missatge de la lliçó: el mateix conjunt de regles REST produeix APIs diferents segons el domini, i a 06-02 ho veurem de manera molt més brutal.

  1. Els consumidors: cinc clients, cinc necessitats diferents

A 01-01 vam dir que una API es dissenya per als seus consumidors, no per a la seva base de dades. La Botiga Aroma en té cinc, i cadascun estira el disseny en una direcció.

Consumidor Qui és Què necessita Com va estirar el disseny
SPA botigaaroma.example Web pública, navegador Catàleg ràpid, cistella, compra Va forçar CORS amb llista blanca (04-05), memòria cau amb ETag (04-06) i _links a les comandes
Aroma Mòbil App nativa iOS/Android El mateix però amb xarxa dolenta i dades cares Va forçar camps per a respostes parcials, compressió i tolerància als reintents
Tauler intern panel.botigaaroma.example Empleats i administradors Moderar ressenyes, gestionar comandes, veure en directe Va forçar rols (empleat, administrador) i SSE per al temps real
RàpidEnviaments Transportista, sistema a sistema Assabentar-se de les comandes pagades sense sondejar Va forçar webhooks signats amb HMAC-SHA256 i reintents
CataBox App de tercers, socis Llegir el catàleg i escriure ressenyes en nom de l'usuari Va forçar OAuth 2.0 amb àmbits (04-03) i el rol soci

El detall important: els cinc consumeixen la mateixa API. No hi ha una API per a mòbil i una altra per a web. Va ser una decisió conscient, amb el seu cost: la SPA rep alguns camps que no fa servir, i el mòbil ha de demanar camps=id,nom,preuEuros per aprimar la resposta. L'alternativa —un backend for frontend per client— hauria donat respostes perfectes per a cadascun a canvi de multiplicar per tres el codi, les proves i el contracte. Amb cinc consumidors i un equip petit, no compensava. Amb vint consumidors i tres equips, la resposta hauria estat una altra.

  1. Casos d'ús crítics i requisits no funcionals

Quatre casos d'ús concentren el 95 % del trànsit i tot el risc:

  1. Cercar un cafèGET /cafes amb filtres. És el 70 % de les peticions. Ha de ser rapidíssim i és totalment cacheable.
  2. Comprar — cistella, comanda, pagament. És el 5 % de les peticions i el 100 % dels ingressos. Ha de ser correcte encara que sigui lent.
  3. Seguir l'enviamentGET /comandes/{id} i /enviament. Genera sondeig repetit: molta memòria cau condicional i 304.
  4. Moderar ressenyes — tauler intern. Volum baix, autorització estricta.

I els requisits no funcionals que vam acordar amb negoci:

Requisit Objectiu On es va resoldre
Disponibilitat 99,9 % mensual (uns 43 min de caiguda) Rèpliques, readiness, blue-green (05-05)
Latència de lectura p95 < 200 ms, p99 < 500 ms Memòria cau HTTP + Redis (04-06), índexs (03-05)
Latència d'escriptura p95 < 400 ms Transaccions curtes, webhooks asíncrons
Pic de campanya 30× el trànsit base durant 6 setmanes Memòria cau de catàleg, escalat horitzontal, rate limiting (04-04)
Protecció de dades RGPD: accés, rectificació, supressió Anonimització, minimització de logs (04-02)
Correcció de la comanda Zero comandes duplicades, zero sobrevenda Idempotency-Key + transacció + 409

Fixa't en l'asimetria deliberada: la lectura s'optimitza per a la velocitat i l'escriptura per a la correcció. Un catàleg que triga 400 ms molesta; una comanda cobrada dues vegades és una trucada al banc, una devolució i un client perdut.

  1. Del model de domini als recursos

A 02-02 vam veure el mètode: escriu com descriu el negoci la seva feina, subratlla els substantius i pregunta't quins tenen identitat pròpia, estat i cicle de vida. Aplicat a la Botiga Aroma:

Substantiu del negoci És recurs? Raó
Cafè Sí, col·lecció /cafes Identitat pròpia, es llista, es filtra, s'enllaça
Client Sí, /clients Identitat pròpia i dades personals
Comanda Sí, /comandes Identitat, estat i cicle de vida llarg
Ressenya Sí, /ressenyes Identitat pròpia; es modera de manera independent
Cistella , /cistelles Té estat que sobreviu entre peticions
Línia de cistella Sí, subrecurs /cistelles/{id}/linies/{cafeId} Es manipula individualment
Sessió Sí, /sessions L'inici de sessió com a creació d'un recurs
Imatge de cafè Sí, /cafes/{id}/imatge Representació binària amb la seva pròpia memòria cau
Procés de compra No És un procés, no una cosa
Cerca No És un GET /cafes amb paràmetres
Descompte No (v1) Es va resoldre com a camp calculat de la comanda

Per què la cistella és un recurs i el procés de compra no

Aquesta és la distinció que més costa i la que millor separa qui ha entès REST de qui tradueix funcions a URL.

La cistella és un recurs perquè compleix les tres proves: té identitat (cis_77), té estat que persisteix entre peticions (les línies que hi has afegit hi continuen sent demà) i respon amb sentit als mètodes HTTP. GET /cistelles/cis_77 retorna alguna cosa. DELETE /cistelles/cis_77 significa buidar-la. La pots enllaçar. La pots posar a la memòria cau (malament, perquè canvia, però el verb té sentit).

El procés de compra no és un recurs perquè és un procés: la transició d'una cistella a una comanda. No té estat propi per consultar. Un GET /compra no significa res. I sobretot: el resultat del procés que és un recurs, i ja té nom. El procés s'expressa creant aquest recurs:

POST /v1/comandes
Idempotency-Key: 6f1b2c9e-8a4d-4f7a-9c3e-2b5d7e1f0a44

{"cistellaId": "cis_77", "adrecaEnviamentId": "adr_12"}

L'alternativa hauria estat POST /compra, que funciona però no diu què s'ha creat, no pot retornar un Location coherent i no es deixa versionar ni enllaçar. La regla que en traiem: si un procés produeix alguna cosa amb identitat, exposa el que produeix, no el procés. I si un procés no produeix res de nou però canvia l'estat d'alguna cosa existent —pagar, enviar, anul·lar—, exposa aquesta transició com a subrecurs amb POST, que és exactament el que vam fer amb /comandes/{id}/pagament.

  1. El mapa complet d'URIs

Aquest és el contracte complet de la v1, tal com va quedar:

https://api.botigaaroma.example/v1

Catàleg
  GET    /cafes                        Llistar (filtres, ordre, desplaçament)
  POST   /cafes                        Crear                    [administrador]
  GET    /cafes/{id}                   Detall
  PUT    /cafes/{id}                   Substituir (If-Match)    [administrador]
  PATCH  /cafes/{id}                   Modificar (If-Match)     [administrador]
  DELETE /cafes/{id}                   Retirar                  [administrador]
  GET    /cafes/{id}/imatge            Imatge (binari, cau llarga)
  PUT    /cafes/{id}/imatge            Substituir imatge        [administrador]
  GET    /cafes/{id}/ressenyes         Ressenyes del cafè
  POST   /cafes/{id}/ressenyes         Publicar ressenya        [client|soci]

Clients
  GET    /clients                      Llistar                  [empleat+]
  POST   /clients                      Alta
  GET    /clients/{id}                 Detall                   [propietari|empleat+]
  PATCH  /clients/{id}                 Modificar                [propietari|administrador]
  DELETE /clients/{id}                 Baixa (anonimitza)       [propietari|administrador]
  GET    /clients/{id}/preferencies    Preferències
  PUT    /clients/{id}/preferencies    Substituir preferències
  GET    /clients/{id}/comandes        Comandes del client (cursor)

Cistelles
  POST   /cistelles                    Crear cistella
  GET    /cistelles/{id}               Veure cistella
  DELETE /cistelles/{id}               Buidar
  PUT    /cistelles/{id}/linies/{cafeId}   Fixar quantitat (idempotent)
  DELETE /cistelles/{id}/linies/{cafeId}   Treure línia

Comandes
  GET    /comandes                     Llistar (cursor)
  POST   /comandes                     Crear (Idempotency-Key)
  GET    /comandes/{id}                Detall (ETag, _links)
  POST   /comandes/{id}/pagament       Pagar
  POST   /comandes/{id}/enviament      Marcar enviada          [empleat+]
  GET    /comandes/{id}/factura        Descarregar factura (PDF)
  POST   /comandes/{id}/anullacio      Anul·lar
  POST   /comandes/{id}/devolucio      Retornar

Ressenyes
  GET    /ressenyes                    Llistar (moderació)     [empleat+]
  GET    /ressenyes/{id}               Detall
  POST   /ressenyes/{id}/aprovacio     Aprovar                 [ressenyes.moderar]
  POST   /ressenyes/{id}/rebuig        Rebutjar                [ressenyes.moderar]
  POST   /ressenyes/{id}/respostes     Respondre               [empleat+]

Sessions i sistema
  POST   /sessions                     Inici de sessió (retorna JWT)
  DELETE /sessions/actual              Tancament de sessió
  GET    /salut/viu                    Liveness
  GET    /salut/llest                  Readiness
  GET    /metriques                    Prometheus              [intern]
  GET    /docs                         Swagger UI

Tres regularitats que sostenen tot el mapa i que un consumidor nou aprèn en cinc minuts:

  • Plural sempre, sense excepcions. /cafes, no /cafe ni /coffeeList.
  • Sense verbs a la ruta; el verb és el mètode HTTP. Les úniques "accions" són substantius de transició (/pagament, /anullacio).
  • Màxim dos nivells d'imbricació, i el subrecurs sempre pertany de debò al pare.

  1. Les decisions de disseny i les seves alternatives descartades

Aquesta és la taula que més valor té en una memòria tècnica. Cada fila és una bifurcació real del projecte.

# Decisió presa Alternativa descartada Per què
1 Accions com a subrecurs amb POST (/comandes/{id}/pagament) Verbs a la ruta (/comandes/{id}/pagar) o PATCH amb {"estat":"pagat"} El subrecurs permet cos propi, resposta pròpia i àmbits OAuth diferents. El PATCH d'estat converteix la màquina d'estats en un camp editable, i llavors res no impedeix saltar de pendent_pagament a enviat
2 Imbricació màxima de dos nivells /clients/{c}/comandes/{p}/linies/{l} Amb tres nivells, la URI d'una línia deixa de ser estable si la comanda canvia de client, i obliga a conèixer tota la jerarquia per enllaçar
3 Identificadors amb prefix (caf_001, com_5001) Enters autoincrementals o UUID pelats El prefix fa els errors obvis als logs i al suport (cafe_no_trobat: com_5001 canta sol), no filtra volum de negoci i permet canviar l'emmagatzematge sense canviar el format públic
4 Embolcall {"dades": [...], "total": n} a les col·leccions Array pelat [...] Deixa lloc per a metadades futures sense trencar el contracte. (Vegeu l'autocrítica de l'apartat 11: la decisió va ser correcta però incompleta)
5 _links selectius: només a les comandes i segons l'estat HATEOAS complet a tots els recursos, o cap Richardson 2 amb hipermèdia on aporta. En una comanda, saber si pagament està disponible evita que el client reimplementi la màquina d'estats. En un cafè, un self és tot el que ningú no farà servir
6 Diners en cèntims per dins, euros amb dos decimals per fora Flotants a tot arreu, o cèntims també al JSON 0.1 + 0.2 !== 0.3 arruïna els totals. Però exposar 1450 obliga cada consumidor a saber l'escala; exposar "14.50" és inequívoc i llegible al navegador
7 Paginació per desplaçament a /cafes, per cursor a /comandes Un únic model per a tota l'API Coherència mal entesa. El catàleg és petit i estable i la gent vol anar a la "pàgina 3"; les comandes creixen sense fi i s'insereixen per davant, on el desplaçament produeix duplicats i salts (02-06)
8 Versió a la ruta (/v1) Capçalera Accept amb perfil, o paràmetre ?versio= Visible als logs, al navegador, a les mètriques per ruta i a la configuració del gateway. La capçalera és més pura i molt menys operable
9 Errors propis {"error":{"codi",...}} application/problem+json (RFC 9457) Es va triar per familiaritat de l'equip. (Vegeu l'apartat 11: va ser un error)
10 Ressenyes imbricades per escriure (POST /cafes/{id}/ressenyes) i planes per moderar (GET /ressenyes) Només imbricades, o només planes Escriure sempre és en el context d'un cafè; moderar no ho és mai. (Amb matisos: apartat 11)
11 Idempotency-Key obligatòria a POST /comandes i /pagament Confiar que el client no reintenti Les xarxes mòbils reintenten soles. Un timeout no diu si el servidor va processar la petició
12 Prefix propi Aroma- a les capçaleres no estàndard X- (obsolet des de la RFC 6648) X- està desaconsellat i col·lisiona; el prefix de marca és inequívoc

  1. El flux complet d'una compra

Aquí és on el disseny es posa a prova. Seguim la Marta Garcia (cli_842) des que busca cafè fins que descarrega la factura. Totes les peticions són reals segons el contracte de la v1.

Pas 1 — Cercar al catàleg

GET /v1/cafes?torrefaccio=clar&preuMax=16.00&disponible=true&ordenar=-puntuacioMitjana&limit=20 HTTP/1.1
Host: api.botigaaroma.example
Accept: application/json
Origin: https://botigaaroma.example
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=60, stale-while-revalidate=300
ETag: "cat-9f2a17b4"
Vary: Accept, Accept-Encoding, Origin
Link: <https://api.botigaaroma.example/v1/cafes?torrefaccio=clar&limit=20&desplacament=20>; rel="next"
Aroma-RateLimit-Restants: 98

{
  "dades": [
    {
      "id": "caf_001",
      "nom": "Etiòpia Yirgacheffe",
      "origen": "Etiòpia",
      "torrefaccio": "clar",
      "preuEuros": "14.50",
      "estoc": 120,
      "notesTast": ["gessamí", "bergamota", "préssec"],
      "versio": 7,
      "_links": { "self": { "href": "/v1/cafes/caf_001" } }
    }
  ],
  "total": 1
}

Sense autenticació: el catàleg és públic. Amb ETag, perquè la visita següent rebi un 304 de 150 bytes (04-06). Amb Vary: Origin perquè la resposta porta capçaleres CORS i una memòria cau compartida no les ha de barrejar (04-05).

Pas 2 — Afegir a la cistella

PUT /v1/cistelles/cis_77/linies/caf_001 HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json

{"quantitat": 2}
HTTP/1.1 200 OK
Content-Type: application/json

{
  "cafeId": "caf_001",
  "nom": "Etiòpia Yirgacheffe",
  "quantitat": 2,
  "preuUnitariEuros": "14.50",
  "subtotalEuros": "29.00"
}

PUT i no POST. Aquesta és una de les decisions més útils de tot el contracte i val la pena explicar-la. Amb POST /cistelles/cis_77/linies, si l'usuari prem dues vegades "afegir" acaba amb dues línies del mateix cafè, o amb una lògica de fusió amagada al servidor. Amb PUT sobre la URI de la línia, l'operació és idempotent (02-03): "la quantitat de caf_001 en aquesta cistella és 2". Prémer deu vegades deixa el mateix resultat. La interfície de la cistella, amb el seu selector de quantitat, hi encaixa de manera natural: cada canvi del selector és un PUT.

I el subrecurs té URI pròpia, així que treure un cafè és DELETE /v1/cistelles/cis_77/linies/caf_001, sense cos i sense ambigüitat.

Aquí no es reserva estoc. És deliberat: reservar a la cistella obliga a fer caducar reserves, complica l'inventari i genera falsos "exhaurit" en campanya. L'estoc es comprova i es descompta en crear la comanda, dins d'una transacció.

Pas 3 — Crear la comanda

POST /v1/comandes HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json
Idempotency-Key: 6f1b2c9e-8a4d-4f7a-9c3e-2b5d7e1f0a44

{"cistellaId": "cis_77", "adrecaEnviamentId": "adr_12"}
HTTP/1.1 201 Created
Location: /v1/comandes/com_5001
ETag: "com-5001-v1"
Content-Type: application/json

{
  "id": "com_5001",
  "clientId": "cli_842",
  "estat": "pendent_pagament",
  "linies": [
    {"cafeId": "caf_001", "nom": "Etiòpia Yirgacheffe", "quantitat": 2,
     "preuUnitariEuros": "14.50", "subtotalEuros": "29.00"}
  ],
  "totalEuros": "29.00",
  "dataCreacio": "2026-08-15T09:14:22Z",
  "versio": 1,
  "_links": {
    "self":      {"href": "/v1/comandes/com_5001"},
    "pagament":  {"href": "/v1/comandes/com_5001/pagament", "method": "POST"},
    "anullacio": {"href": "/v1/comandes/com_5001/anullacio", "method": "POST"}
  }
}

A dins, tot passa dins d'una transacció (03-05): es llegeixen les línies de la cistella, es bloquegen i es comproven els estocs, es congela el preu unitari a cada línia, es calcula el total en cèntims, s'insereix la comanda, es descompta l'estoc i es buida la cistella. Si alguna cosa falla, no en queda cap rastre.

I a _links apareix la hipermèdia selectiva de la decisió 5: com que la comanda està pendent_pagament, el client veu pagament i anullacio. No veurà enviament ni devolucio, perquè encara no són possibles. La SPA no necessita conèixer la màquina d'estats: en té prou de pintar els enllaços que rep.

Pas 4 — Pagar

POST /v1/comandes/com_5001/pagament HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Idempotency-Key: 3d8e5a11-77c0-4b2e-9a10-4c6f8b0e2d31
If-Match: "com-5001-v1"
Content-Type: application/json

{"metode": "targeta", "tokenPassarella": "tok_fictici_9f3a"}
HTTP/1.1 200 OK
ETag: "com-5001-v2"
Content-Type: application/json

{
  "id": "com_5001",
  "estat": "pagat",
  "totalEuros": "29.00",
  "versio": 2,
  "_links": {
    "self":      {"href": "/v1/comandes/com_5001"},
    "factura":   {"href": "/v1/comandes/com_5001/factura"},
    "enviament": {"href": "/v1/comandes/com_5001/enviament"}
  }
}

Tres mecanismes actuant alhora, i convé distingir-los perquè es confonen:

  • Idempotency-Key protegeix contra el reintent del mateix client: si la resposta es va perdre, repetir la petició retorna la resposta desada sense tornar a cobrar.
  • If-Match protegeix contra l'escriptura sobre una versió obsoleta: si un altre procés ja ha canviat la comanda, respon 412 (04-06 i 03-05).
  • La transició d'estat protegeix contra allò semànticament impossible: pagar dues vegades amb claus diferents respon 409 comanda_ja_pagada.

I observa com canvien els _links: han desaparegut pagament i anullacio, i han aparegut factura i enviament. Els enllaços són la màquina d'estats.

Pas 5 — El webhook cap a RàpidEnviaments

El pagament dispara un esdeveniment. L'API no crida RàpidEnviaments dins de la transacció; encua l'esdeveniment i el lliura després, perquè un transportista lent no pot bloquejar un cobrament.

POST /hooks/aroma HTTP/1.1
Host: api.rapidenviaments.example
Content-Type: application/json
Aroma-Esdeveniment-Id: evt_88213
Aroma-Signatura: sha256=9c1f...4b7e
Aroma-Traca-Id: 4bf92f3577b34da6a3ce929d0e0e4736

{
  "esdeveniment": "comanda.pagada",
  "dataCreacio": "2026-08-15T09:15:03Z",
  "dades": {
    "comandaId": "com_5001",
    "totalEuros": "29.00",
    "destinatari": {"nom": "Marta Garcia", "codiPostal": "46001"}
  }
}

La signatura és HMAC-SHA256 del cos cru amb el secret compartit. RàpidEnviaments la verifica, respon 2xx i encua la seva feina. Si respon 5xx o no respon, ho reintentem amb espera exponencial. Aroma-Esdeveniment-Id permet a RàpidEnviaments descartar duplicats: el lliurament és "com a mínim una vegada", així que el receptor ha de ser idempotent.

Pas 6 — Seguir l'enviament i descarregar la factura

Quan RàpidEnviaments recull el paquet, un empleat (o la seva integració) marca l'enviament:

POST /v1/comandes/com_5001/enviament HTTP/1.1
Authorization: Bearer <token amb ambit enviaments.escriure>
Content-Type: application/json

{"transportista": "RapidEnviaments", "seguiment": "RE9928471ES"}

La Marta consulta l'estat. Com que sondeja cada minut, la memòria cau condicional fa la seva feina:

GET /v1/comandes/com_5001 HTTP/1.1
If-None-Match: "com-5001-v3"

HTTP/1.1 304 Not Modified
ETag: "com-5001-v3"
Cache-Control: private, no-cache

I la factura, que és un recurs amb una altra representació:

GET /v1/comandes/com_5001/factura HTTP/1.1
Accept: application/pdf

HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="factura-com_5001.pdf"
Cache-Control: private, max-age=31536000, immutable

immutable perquè una factura emesa no canvia mai. És un dels pocs llocs de tota l'API on aquesta directiva està plenament justificada.

El flux en un diagrama

sequenceDiagram
  participant S as SPA
  participant G as Gateway Kong
  participant A as API Aroma
  participant D as Base de dades
  participant R as RapidEnviaments

  S->>G: GET /v1/cafes?torrefaccio=clar
  G->>A: reenvia
  A-->>S: 200 + ETag + Cache-Control
  S->>A: PUT /cistelles/cis_77/linies/caf_001
  A-->>S: 200 linia fixada
  S->>A: POST /comandes (Idempotency-Key)
  A->>D: TX: comprovar estoc, congelar preu, inserir
  D-->>A: ok
  A-->>S: 201 Created + Location + _links(pagament)
  S->>A: POST /comandes/com_5001/pagament (If-Match)
  A->>D: TX: estat=pagat, versio=2
  A-->>S: 200 + _links(factura, enviament)
  A->>R: POST webhook comanda.pagada (Aroma-Signatura)
  R-->>A: 202 acceptat
  R->>A: POST /comandes/com_5001/enviament
  A-->>R: 200 estat=enviat
  S->>A: GET /comandes/com_5001 (If-None-Match)
  A-->>S: 304 Not Modified

  1. Casos difícils i com es van resoldre

Un disseny es jutja pel que fa quan les coses van malament. Aquests sis casos són els que de debò van costar reunions.

8.1 Estoc insuficient amb concurrència

El problema. Queden 2 unitats de caf_001 i dos clients creen una comanda de 2 en el mateix instant. Si comprovem l'estoc i després el descomptem en dos passos separats, tots dos llegeixen 2, tots dos veuen que n'hi ha prou i tots dos venen. Sobrevenda.

La solució. Tot dins d'una transacció, i el descompte amb la condició incorporada a la mateixa sentència:

BEGIN IMMEDIATE;

UPDATE cafes
   SET estoc = estoc - :quantitat
 WHERE id = :cafeId
   AND estoc >= :quantitat;
-- si changes() = 0, no hi havia estoc: s'avorta

INSERT INTO comandes (...) VALUES (...);
COMMIT;

La comprovació i l'escriptura són la mateixa operació atòmica. Si changes() retorna 0, no hi havia estoc i es llança l'error:

// src/serveis/comandes.js (extracte)
const resultat = repositoriCafes.descomptarEstoc(cafeId, quantitat);
if (resultat.changes === 0) {
  throw new ErrorApi(409, 'estoc_insuficient',
    'No hi ha prou unitats del cafè demanat', [
      { camp: 'linies[0].quantitat', cafeId, demanat: quantitat }
    ]);
}

Per què 409 i no 400. El 400 diu "la teva petició està mal escrita"; reenviar-la igual sempre fallarà. El 409 diu "la teva petició és vàlida però xoca amb l'estat actual"; demà, amb l'estoc reposat, la mateixa petició funcionarà. La diferència importa per al client, que en el segon cas pot oferir "avisa'm quan n'hi hagi".

8.2 Doble pagament

El problema. El mòbil de la Marta perd cobertura just després d'enviar POST /pagament. El servidor cobra; la resposta no arriba. L'app ho reintenta. Es cobra dues vegades?

La solució, en dues capes.

La primera és la Idempotency-Key. Abans de processar, s'intenta inserir la clau en una taula amb restricció única, juntament amb l'empremta del cos:

// src/middleware/idempotencia.js (extracte)
const registre = repositoriIdempotencia.cercar(clau);
if (registre) {
  if (registre.empremtaCos !== hashDe(req.body)) {
    throw new ErrorApi(422, 'clau_idempotencia_reutilitzada',
      "Aquesta clau d'idempotència ja es va fer servir amb un cos diferent");
  }
  if (registre.estat === 'en_curs') {
    throw new ErrorApi(409, 'operacio_en_curs',
      "L'operació amb aquesta clau encara s'està processant");
  }
  return res.status(registre.codi).set(registre.capcaleres).json(registre.resposta);
}

El reintent retorna la mateixa resposta desada, amb el mateix 201/200 i el mateix cos. No es cobra dues vegades.

La segona capa és la màquina d'estats: si arriba un pagament amb una clau nova sobre una comanda que ja està pagat, respon 409 comanda_ja_pagada. La idempotència cobreix el reintent; l'estat cobreix l'error genuí.

8.3 El preu canvia amb la comanda ja creada

El problema. La Marta crea la comanda a les 9:14 amb caf_001 a 14,50 €. A les 9:20, un administrador puja el preu a 15,90 €. La Marta paga a les 9:25. Quant paga?

La solució: preu congelat a la línia. Cada línia de comanda desa el seu propi preu_centims, copiat del catàleg en el moment de la creació. La comanda no consulta el preu del cafè ni en mostrar-se ni en pagar-se.

CREATE TABLE linies_comanda (
  comanda_id    TEXT    NOT NULL,
  cafe_id       TEXT    NOT NULL,
  nom_cafe      TEXT    NOT NULL,  -- copiat, no referenciat
  quantitat     INTEGER NOT NULL CHECK (quantitat > 0),
  preu_centims  INTEGER NOT NULL, -- congelat
  PRIMARY KEY (comanda_id, cafe_id)
);

Fixa't que també es copia el nom. No és redundància per descuit: si el cafè es reanomena o es retira del catàleg, la factura de la Marta ha de continuar dient què va comprar. Una comanda és un document històric, no una vista de dades actuals, i aquesta distinció canvia com es modela la taula.

La conseqüència que cal assumir: una cistella vella pot mostrar preus desactualitzats. Es va resoldre recalculant els preus de la cistella a cada GET /cistelles/{id} (la cistella sí que és una vista actual) i avisant a la interfície si alguna cosa havia canviat des de l'última visita.

8.4 Ressenya de qui no ha comprat el cafè

El problema. Pot cli_842 ressenyar caf_002 si no l'ha comprat mai?

La decisió de negoci va ser: sí, però amb distinció visible. Ressenyar és una barrera baixa a propòsit, perquè el volum de ressenyes importa comercialment. Però una ressenya d'un comprador verificat val més.

La solució tècnica. En crear la ressenya, el servei consulta si existeix alguna comanda pagada d'aquest client que contingui aquest cafè, i segella el resultat al recurs:

// src/serveis/ressenyes.js (extracte)
const compraVerificada = repositoriComandes.clientHaCompratCafe(clientId, cafeId);

return repositoriRessenyes.crear({
  cafeId, clientId, puntuacio, comentari,
  compraVerificada,                       // segell immutable
  estat: 'pendent_moderacio'              // tota ressenya es modera
});
{
  "id": "res_101",
  "cafeId": "caf_001",
  "clientId": "cli_842",
  "puntuacio": 5,
  "comentari": "Floral i net, molt recomanable.",
  "compraVerificada": true,
  "estat": "pendent_moderacio",
  "dataCreacio": "2026-08-15T10:02:00Z"
}

I una regla afegida: una ressenya per client i cafè, amb restricció única a la base de dades. Un segon intent respon 409. Aquesta regla viu a l'índex, no només al servei, perquè amb dues instàncies de l'API en paral·lel la comprovació en codi no n'hi ha prou.

8.5 Esborrar un client que té comandes

El problema. La Marta exerceix el seu dret de supressió (RGPD, article 17). Però les seves comandes són documents comptables que la legislació mercantil obliga a conservar uns quants anys. Dues obligacions legals que apunten en direccions oposades.

La solució: anonimitzar, no esborrar. DELETE /clients/cli_842 no executa cap DELETE a la taula. Substitueix les dades personals per valors neutres, conserva el registre amb el seu identificador i desactiva el compte:

UPDATE clients
   SET nom            = 'Client eliminat',
       email          = '[email protected]',
       telefon        = NULL,
       adreces        = NULL,
       anonimitzat_el = :ara,
       actiu          = 0
 WHERE id = :clientId;
-- Les comandes conserven client_id: l'import i la data continuen sent auditables,
-- però ja no hi ha manera de saber qui va ser.

La resposta és 204 No Content. A partir d'aquí, GET /clients/cli_842 respon 404 client_no_trobat a qualsevol que no sigui auditoria interna, i la comanda com_5001 continua existint amb el seu import, la seva data i el seu IVA, però sense cap persona al darrere.

Advertiment important. El que acabes de llegir és una solució tècnica plausible, no assessorament legal. Què es pot conservar, durant quant de temps, amb quina base jurídica i què compta com a anonimització efectiva —davant de la mera pseudonimització, que continua sent dada personal— depèn de la jurisdicció, del sector i del cas concret. En un projecte real, aquest disseny es valida amb el responsable de protecció de dades i amb assessoria jurídica abans d'escriure la primera línia de codi. També cal decidir què fer amb les còpies de seguretat, amb els logs i amb els sistemes tercers als quals es va enviar la dada (aquí, RàpidEnviaments), i això rarament ho resol un UPDATE.

8.6 Un webhook que RàpidEnviaments no va confirmar

El problema. Enviem comanda.pagada i no arriba resposta. El van rebre? El van processar? No hi ha manera de saber-ho des de fora.

La solució: cua persistent amb reintents i desactivació. L'esdeveniment es desa en una taula amb el seu estat, i un procés el reintenta amb espera exponencial i jitter: 1 min, 2, 4, 8, 16, 32, 64 min. Després de set intents fallits passa a fallada_permanent, es dispara una alerta i l'esdeveniment queda disponible per a reenviament manual des del tauler.

// src/serveis/webhooks.js (extracte)
const ESPERES_MINUTS = [1, 2, 4, 8, 16, 32, 64];

function calcularProximIntent(numeroIntent) {
  const base = ESPERES_MINUTS[numeroIntent] ?? 64;
  const jitter = Math.random() * base * 0.2;  // evita tempestes sincronitzades
  return new Date(Date.now() + (base + jitter) * 60_000);
}

Tres detalls que fan que això funcioni en producció:

  • Aroma-Esdeveniment-Id estable entre reintents. El mateix esdeveniment es reintenta amb el mateix identificador, perquè el receptor pugui descartar duplicats. Si canviés, cada reintent semblaria un esdeveniment nou i RàpidEnviaments crearia set enviaments.
  • Lliurament "com a mínim una vegada", mai "exactament una vegada". És impossible garantir el segon sobre una xarxa no fiable, i prometre-ho a la documentació és enganyar. El que es documenta és: reintentem; sigues idempotent.
  • Curtcircuit. Si RàpidEnviaments porta 50 fallades seguides, es deixa d'intentar durant uns minuts en comptes de castigar un servei que ja és caigut.

  1. L'arquitectura desplegada

graph TB
  SPA[SPA web] --> CDN[CDN]
  MOV[Aroma Mobil] --> GW
  PAN[Tauler intern] --> GW
  CB[CataBox OAuth] --> GW
  CDN --> GW[Gateway Kong<br/>TLS, rate limit, CORS, JWT]

  GW --> API1[API Aroma 1]
  GW --> API2[API Aroma 2]
  GW --> API3[API Aroma 3]

  API1 --> RED[(Redis<br/>cache + rate limit + idempotencia)]
  API2 --> RED
  API3 --> RED
  API1 --> DB[(Base de dades<br/>primaria + replica)]
  API2 --> DB
  API3 --> DB
  API2 --> INV[Servei d'inventari<br/>gRPC intern]
  API2 -.webhook signat.-> RE[RapidEnviaments]
  PAN -.SSE.-> API3
  API1 --> OBS[Observabilitat<br/>Prometheus + traces + logs]

Cada peça és on és per una raó concreta:

Peça Què resol Què passaria sense ella
CDN Serveix imatges de cafè i respostes públiques cacheades El pic de Nadal arribaria sencer a l'API
Gateway Kong TLS, rate limiting global, CORS, validació de JWT, quotes per consumidor Cada instància repetiria aquestes regles i divergirien
3 instàncies sense estat Escalat horitzontal i desplegament blue-green No es podria desplegar sense tall ni absorbir pics
Redis Memòria cau cache-aside, comptadors de rate limit, claus d'idempotència Els límits serien per instància (i per tant 3× el real) i la idempotència no creuaria instàncies
Primària + rèplica Escriptures a la primària, lectures pesades a la rèplica El catàleg competiria amb les comandes per la mateixa base
Inventari per gRPC Consulta d'estoc del magatzem físic, contracte tipat, baixa latència REST intern amb més latència i sense tipus compartits (01-07)
Observabilitat Senyals d'or, traces correlacionades per Aroma-Traca-Id Diagnosticar a cegues (04-07)

Per què la idempotència viu a Redis i no en memòria. Amb tres instàncies darrere del gateway, el reintent del mòbil de la Marta pot aterrar en una instància diferent de l'original. Una memòria cau en memòria no ho veuria i tornaria a cobrar. És el mateix raonament que va portar el rate limiting a Redis a 04-04: qualsevol estat compartit entre peticions ha de viure fora del procés, o l'escalat horitzontal el trenca en silenci.

  1. Mètriques i SLO del servei

Els SLO es deriven dels requisits de l'apartat 3, i cadascun té el seu indicador mesurable (04-07):

SLO Objectiu Indicador Pressupost d'error mensual
Disponibilitat de lectura 99,95 % % de GET sense 5xx ~22 min
Disponibilitat de compra 99,9 % % de POST /comandes i /pagament sense 5xx ~43 min
Latència de catàleg p95 < 200 ms Histograma per ruta
Latència de compra p95 < 400 ms Histograma per ruta
Lliurament de webhooks 99 % en < 5 min % d'esdeveniments confirmats
Correcció de comandes 0 duplicades Comptador de col·lisions d'idempotència 0

I les alertes que de debò desperten algú de nit —deliberadament poques, perquè una alerta que sona sense conseqüència entrena l'equip a ignorar-les—:

# extracte de les regles d'alerta
- alerta: TaxaErrors5xxAlta
  expr: sum(rate(http_peticions_total{codi=~"5.."}[5m]))
      / sum(rate(http_peticions_total[5m])) > 0.01
  durant: 5m
  gravetat: critica

- alerta: CompresFallant
  expr: sum(rate(http_peticions_total{ruta="/v1/comandes",metode="POST",codi=~"5.."}[5m])) > 0
  durant: 2m
  gravetat: critica          # qualsevol fallada de compra és diner perdut

- alerta: WebhooksEncallats
  expr: aroma_webhooks_pendents > 100
  durant: 10m
  gravetat: avis

CompresFallant dispara amb un sol error, mentre que la taxa general tolera un 1 %. Aquesta asimetria és intencionada i reflecteix la de l'apartat 3: no tots els endpoints valen el mateix.

  1. El que faríem diferent

Cap memòria tècnica honesta no acaba sense aquesta secció. Quatre decisions que, amb el projecte ja en producció, no repetiríem.

11.1 L'embolcall dades/total: correcte però incomplet

Què vam fer. {"dades": [...], "total": 42}.

Què va fallar. L'embolcall va ser encertat —va deixar lloc per a metadades— però es va quedar a mitges. Les metadades de paginació van acabar repartides entre la capçalera Link i el camp total, i cada consumidor va haver d'aprendre un lloc diferent per a cada cosa. Pitjor: total obliga a un COUNT(*) a cada llistat, que a /comandes amb filtres amplis és la consulta més lenta de tota l'API. I en una col·lecció paginada per cursor, un total exacte és a més conceptualment dubtós.

Què faríem. Un objecte meta explícit, i total opcional sota demanda:

{
  "dades": [ ],
  "meta": {
    "limit": 20,
    "desplacament": 40,
    "total": 128,
    "cursorSeguent": null
  }
}

Cost d'arreglar-ho ara. És un canvi trencador per als cinc consumidors. Va a la llista de la v2, i aquest és exactament el tipus de deute de contracte que es tracta a 06-03.

11.2 No haver fet servir application/problem+json

Què vam fer. Format propi: {"error": {"codi", "missatge", "detalls"}}.

Què va fallar. És un format raonable, coherent i ben documentat. Però és nostre. Existeix un estàndard, la RFC 9457, amb type, title, status, detail i instance, que les biblioteques de client, els gateways i les eines de monitoratge ja entenen. En integrar CataBox vam haver d'explicar el nostre format des de zero i escriure un adaptador; amb problem+json hauria estat una línia de configuració. I el Content-Type hauria estat autodescriptiu, que és justament la restricció de REST que més ens agrada citar (01-04).

Què faríem. problem+json amb extensions pròpies, que l'estàndard permet:

{
  "type": "https://api.botigaaroma.example/errors/estoc-insuficient",
  "title": "Estoc insuficient",
  "status": 409,
  "detail": "Queda 1 unitat de caf_001 i se n'han demanat 2",
  "instance": "/v1/comandes",
  "codi": "estoc_insuficient",
  "detalls": [{"cafeId": "caf_001", "disponible": 1, "demanat": 2}]
}

La lliçó general: abans d'inventar un format, comprova si ja n'existeix un d'estàndard. Que el teu sigui coherent no compensa que el món sencer parli un altre idioma.

11.3 La imbricació de ressenyes: dos camins per al mateix

Què vam fer. POST /cafes/{id}/ressenyes per crear, GET /ressenyes per moderar, GET /cafes/{id}/ressenyes per llistar les d'un cafè.

Què va fallar. Vam acabar amb dues rutes per al mateix recurs, i això es paga en llocs inesperats: dues entrades a l'OpenAPI que cal mantenir sincronitzades, dues rutes a les mètriques per ruta —cosa que dificulta respondre "quantes ressenyes es creen al dia?"—, dues regles de rate limiting, dues rutes al gateway, dos conjunts de proves. Quan va arribar POST /ressenyes/{id}/respostes, l'asimetria es va fer evident: les respostes pengen de la ressenya plana, no del cafè. I algun client va començar a fer servir GET /ressenyes?cafeId=caf_001, que retorna el mateix que GET /cafes/caf_001/ressenyes però amb una altra manera de paginar.

Què faríem. Col·lecció plana /ressenyes com a única font, amb POST /ressenyes incloent cafeId al cos, i GET /cafes/{id}/ressenyes conservat només com a drecera de lectura documentada com a tal —o eliminat, substituït per GET /ressenyes?cafeId=caf_001—.

El matís honest: hi ha un argument sòlid a favor del que vam fer, i és que POST /cafes/{id}/ressenyes fa impossible crear una ressenya sense cafè i deixa el cafeId fora del cos, on no es pot falsificar. No és una decisió òbviament dolenta; és una decisió el cost de la qual no vam valorar bé per endavant.

11.4 No reservar estoc a la cistella

Què vam fer. L'estoc es comprova i es descompta només en crear la comanda.

Què va fallar. Al pic de Nadal, amb lots petits, van augmentar els 409 estoc_insuficient justament al pas final de la compra. Des del punt de vista de l'usuari, és la pitjor experiència possible: has arribat fins al pagament i llavors et diuen que no n'hi ha.

Què faríem. Mantenir la decisió de fons —reservar a la cistella porta més problemes dels que resol— però avisar abans: mostrar l'estoc restant a la cistella, avisar quan queden poques unitats i comprovar la disponibilitat en obrir el procés de compra, no només en confirmar-lo. L'error 409 continuaria existint, però deixaria de ser la primera notícia.

La lliçó general: alguns problemes de disseny d'API es resolen millor amb informació primerenca que amb més maquinària transaccional.

Errors Comuns i Consells

Confondre "acció" amb "recurs" al primer obstacle. Tan bon punt apareix una operació que no encaixa en CRUD, la temptació és POST /comandes/{id}/processarPagamentINotificar. Pregunta't quina cosa produeix o quin estat canvia. Gairebé sempre hi ha un substantiu al darrere: un pagament, una anul·lació, una devolució.

Coherència mal entesa. Fer servir el mateix model de paginació a tota l'API sona a bona pràctica, però a la Botiga Aroma hauria estat un error: el catàleg i les comandes tenen dinàmiques oposades. La coherència que importa és la de criteris, no la de mecanismes: "col·leccions petites i estables per desplaçament, col·leccions grans que creixen per davant per cursor" és una regla coherent que produeix dos mecanismes diferents.

Desar diners en flotants. 0.1 + 0.2 dona 0.30000000000000004. En una línia no es nota; en un total de campanya, sí. Cèntims com a enter per dins, cadena amb dos decimals per fora.

Una comanda que consulta el catàleg per mostrar preus. És l'error de modelatge més car d'aquest domini. Una comanda és història congelada; el catàleg és present. Copia el que necessitis, encara que sembli redundant.

Enviar webhooks dins de la transacció. Si la crida a RàpidEnviaments passa abans del COMMIT, una fallada posterior deixa notificada una comanda que no existeix. I si passa a dins, un transportista lent allarga la transacció i bloqueja la base de dades. Confirma primer, encua després.

Prometre "exactament una vegada" a la documentació de webhooks. No és realitzable sobre una xarxa no fiable. Documenta "com a mínim una vegada" i exigeix idempotència al receptor. És més honest i evita integracions trencades.

Consell: escriu la taula de decisions mentre dissenyes, no després. La columna que importa no és "què vam fer", que es dedueix del codi, sinó "què vam descartar i per què". D'aquí a un any, quan algú proposi canviar el model de paginació, aquesta columna evita repetir la discussió sencera.

Consell: fes el flux complet abans d'escriure codi. Escriure les deu peticions i respostes d'una compra en un fitxer de text, encadenades, revela buits que cap diagrama de recursos no mostra: capçaleres que falten, identificadors que ningú no va retornar, estats impossibles.

Exercicis

Exercici 1 — Un recurs nou: la subscripció

La Botiga Aroma vol llançar subscripcions: el client rep 500 g d'un cafè cada mes, la pot pausar, reprendre, canviar el cafè o cancel·lar-la, i cada mes es genera automàticament una comanda.

  1. Decideix si "subscripció" és un recurs i justifica-ho amb les tres proves de l'apartat 4.
  2. Dissenya el mapa d'URIs complet, incloent-hi pausa, represa, cancel·lació i consulta de les comandes generades.
  3. Indica quins _links retornaries per a una subscripció en estat activa i en estat pausada.
  4. Decideix el model de paginació de /subscripcions i justifica'l.

Exercici 2 — Reproduir i arreglar la sobrevenda

Escriu una prova d'integració que demostri la sobrevenda quan la comprovació d'estoc i el descompte van en dos passos separats, i verifica després que la solució de l'apartat 8.1 l'evita. Comprova també el codi i el cos de l'error.

Exercici 3 — Auditoria de decisions

Pren la taula de l'apartat 6 i, per a cadascuna d'aquestes tres files, argumenta el cas contrari de manera convincent: (a) _links selectius, (b) versió a la ruta, (c) identificadors amb prefix. Després decideix si mantindries la decisió original i per què. L'objectiu és distingir les decisions ben fonamentades de les que se sostenen només per costum.

Solucions

Solució 1

1. És un recurs? Sí, sense dubtar-ho. Identitat: sub_301. Estat persistent: activa, pausada, cancellada, més la data de la propera entrega. Cicle de vida: llarg, amb transicions ben definides. Compleix les tres proves millor que la cistella.

2. Mapa d'URIs:

GET    /v1/subscripcions                       Llistar       [propietari|empleat+]
POST   /v1/subscripcions                       Crear (Idempotency-Key)
GET    /v1/subscripcions/{id}                  Detall (ETag)
PATCH  /v1/subscripcions/{id}                  Canviar cafè/quantitat (If-Match)
DELETE /v1/subscripcions/{id}                  Cancel·lar (o POST .../cancellacio)
POST   /v1/subscripcions/{id}/pausa            Pausar
POST   /v1/subscripcions/{id}/represa          Reprendre
GET    /v1/subscripcions/{id}/comandes         Comandes generades (cursor)
GET    /v1/clients/{id}/subscripcions          Drecera de lectura

Pausar i reprendre són subrecursos amb POST, igual que /pagament: són transicions d'estat, no edicions de camps. Un PATCH {"estat":"pausada"} permetria saltar a qualsevol estat sense control.

Canviar el cafè que és PATCH, perquè és una edició d'un atribut, no una transició del cicle de vida. La distinció és exactament la de la decisió 1 de l'apartat 6.

3. Enllaços per estat:

// activa
"_links": {
  "self":         {"href": "/v1/subscripcions/sub_301"},
  "pausa":        {"href": "/v1/subscripcions/sub_301/pausa", "method": "POST"},
  "cancellacio":  {"href": "/v1/subscripcions/sub_301/cancellacio", "method": "POST"},
  "comandes":     {"href": "/v1/subscripcions/sub_301/comandes"}
}

// pausada
"_links": {
  "self":         {"href": "/v1/subscripcions/sub_301"},
  "represa":      {"href": "/v1/subscripcions/sub_301/represa", "method": "POST"},
  "cancellacio":  {"href": "/v1/subscripcions/sub_301/cancellacio", "method": "POST"},
  "comandes":     {"href": "/v1/subscripcions/sub_301/comandes"}
}

Una subscripció cancellada només tindria self i comandes: és terminal.

4. Paginació: desplaçament. Un client té una, dues o tres subscripcions; ni el més entusiasta arribarà a vint. La col·lecció és minúscula, estable i es consulta sencera. El cursor afegiria complexitat sense resoldre cap problema. Diferent és /subscripcions/{id}/comandes, que creix cada mes durant anys i hereta el cursor de /comandes.

Solució 2

// proves/integracio/estoc-concurrencia.prova.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { crearApp } from '../../src/app.js';
import { prepararBaseDeProves } from '../ajudes/base-dades.js';

test('dues comandes simultanies no poden vendre mes estoc del que hi ha', async (t) => {
  const bd = prepararBaseDeProves();
  bd.prepare('UPDATE cafes SET estoc = 2 WHERE id = ?').run('caf_001');
  const app = crearApp({ bd });

  const cos = { cistellaId: 'cis_77', adrecaEnviamentId: 'adr_12' };

  const [a, b] = await Promise.all([
    request(app).post('/v1/comandes')
      .set('Authorization', `Bearer ${t.tokenDe('cli_842')}`)
      .set('Idempotency-Key', 'clau-a')
      .send(cos),
    request(app).post('/v1/comandes')
      .set('Authorization', `Bearer ${t.tokenDe('cli_001')}`)
      .set('Idempotency-Key', 'clau-b')
      .send(cos)
  ]);

  const codis = [a.status, b.status].sort();
  assert.deepEqual(codis, [201, 409], "una s'ha de crear i l'altra ha de xocar");

  const fallida = a.status === 409 ? a : b;
  assert.equal(fallida.body.error.codi, 'estoc_insuficient');
  assert.ok(Array.isArray(fallida.body.error.detalls));
  assert.equal(fallida.body.error.tracaId, undefined, 'tracaId nomes en 5xx');

  const { estoc } = bd.prepare('SELECT estoc FROM cafes WHERE id = ?').get('caf_001');
  assert.equal(estoc, 0, "l'estoc no pot quedar mai negatiu");
});

Com demostrar primer la fallada. Substitueix temporalment l'UPDATE ... WHERE estoc >= :quantitat per dos passos —SELECT estoc i després UPDATE estoc = :nou— fora de transacció. Amb la versió ingènua veuràs dos 201 i estoc = -2. Restaura la versió correcta i la prova passa. Aquesta prova és valuosa precisament perquè falla amb la implementació ingènua: una prova que passa amb el codi trencat no prova res.

Nota: amb SQLite i better-sqlite3 les escriptures se serialitzen, cosa que ja ajuda; amb PostgreSQL o MySQL, l'UPDATE ... WHERE condicional dins de la transacció és el que garanteix l'atomicitat. La condició a la sentència mateixa és el que és portable.

Solució 3

(a) Cas contrari als _links selectius. «La selectivitat obliga el servidor a calcular els enllaços a cada resposta, és més codi i més proves, i produeix una API inconsistent: uns recursos porten enllaços i d'altres no, cosa que confon qui la descobreix. A més, un client que vulgui saber si una comanda és anul·lable l'ha de demanar sencera. O HATEOAS complet, o cap i que el client conegui l'estat.» Veredicte: es manté. L'argument de la inconsistència és real, però es resol documentant-la, no eliminant el valor. Els enllaços de comanda eviten que cinc consumidors reimplementin —i desincronitzin— la mateixa màquina d'estats. Als cafès no hi havia res a evitar.

(b) Cas contrari a la versió a la ruta. «/v1/cafes/caf_001 i /v2/cafes/caf_001 són URI diferents per al mateix recurs, cosa que contradiu la identificació de recursos de 01-04. La versió és un detall de la representació i el seu lloc natural és Accept. Amb la versió a la ruta, cada client té enllaços fixats al codi i HATEOAS es torna impossible de versionar netament.» Veredicte: es manté, amb el cost reconegut. L'argument és teòricament correcte i l'assumim. A canvi: la versió apareix als logs, a les mètriques i a la configuració del gateway, es prova des del navegador, s'enruta sense lògica a l'aplicació i respon a "qui continua a la v1?" amb una consulta trivial. A 06-03 veuràs quant val això a l'hora de migrar.

(c) Cas contrari als identificadors amb prefix. «El prefix barreja el tipus amb l'identificador, es torna mentida quan un recurs canvia de tipus o es fusiona amb un altre, ocupa bytes a cada resposta i tempta els clients a analitzar l'identificador per deduir-ne el tipus, creant un acoblament amb un detall intern.» Veredicte: es manté. El risc de l'anàlisi és real i es mitiga documentant explícitament que l'identificador és opac. A canvi, cada missatge d'error, cada log i cada tiquet de suport es llegeixen sense consultar la base de dades, i això es cobra cada dia.

  1. Llista de verificació final del projecte

Una llista aplicable a qualsevol API que dissenyis, derivada de tot el que hem recorregut:

Disseny

  • [ ] Els consumidors estan identificats, i cadascun amb la seva necessitat concreta.
  • [ ] Els recursos surten del domini, no de les taules.
  • [ ] URI en plural, sense verbs, amb imbricació màxima de dos nivells.
  • [ ] Cada mètode HTTP respecta la seva semàntica: GET segur, PUT/DELETE idempotents.
  • [ ] Els codis d'estat distingeixen 400, 409, 412, 422 i 429 amb criteri.
  • [ ] Catàleg d'errors tancat, documentat i amb codis estables.
  • [ ] Model de paginació triat per domini i justificat per escrit.
  • [ ] Estratègia de versionat i política de deprecació publicades.

Implementació

  • [ ] Validació de tota l'entrada a la vora, amb esquemes.
  • [ ] Transaccions on hi ha invariants; condicions dins de la sentència.
  • [ ] Concurrència optimista amb versio i ETag/If-Match on importa.
  • [ ] Idempotència en tota operació amb efectes de diners o de tercers.
  • [ ] Errors unificats per un únic gestor.
  • [ ] Diners en enters; dates en ISO-8601 UTC.
  • [ ] Dades històriques copiades, no referenciades.

Seguretat

  • [ ] Autenticació i autorització per rol i per propietat del recurs.
  • [ ] CORS amb llista blanca explícita; mai * amb credencials.
  • [ ] Rate limiting amb estat compartit i 429 amb Retry-After.
  • [ ] Secrets fora del codi; capçaleres de seguretat amb helmet.
  • [ ] Dades personals minimitzades als logs, i esborrat amb criteri legal validat.

Operació

  • [ ] Logs estructurats amb identificador de traça propagat.
  • [ ] Mètriques dels senyals d'or i SLO acordats amb negoci.
  • [ ] liveness i readiness diferenciats.
  • [ ] Alertes poques i accionables, prioritzades per impacte de negoci.
  • [ ] Memòria cau HTTP calibrada recurs a recurs.

Contracte i lliurament

  • [ ] OpenAPI complet, validat amb Spectral i publicat.
  • [ ] Proves unitàries, d'integració, de contracte i e2e a CI.
  • [ ] Detecció automàtica de canvis trencadors.
  • [ ] Desplegament sense tall amb migracions retrocompatibles i tornada enrere.
  • [ ] Decisions importants registrades com a ADR, amb la seva alternativa descartada.

Conclusió

Has recorregut l'API de la Botiga Aroma sencera, dels requisits al desplegament, i sobretot has vist per què és com és. La cistella és recurs i el procés de compra no, perquè l'una té estat i l'altre és un procés. Els diners van en cèntims per dins i en euros per fora, perquè l'aritmètica i la llegibilitat demanen coses diferents. Els cafès es paginen per desplaçament i les comandes per cursor, perquè són col·leccions amb dinàmiques oposades i forçar un únic mecanisme hauria estat coherència mal entesa. La Idempotency-Key és al pagament perquè les xarxes mòbils reintenten soles i un timeout no diu si el servidor va cobrar.

Has vist també el que poques vegades s'ensenya: els casos difícils resolts amb nom i cognoms —la sobrevenda tancada amb una condició dins de l'UPDATE, el doble pagament tancat en dues capes, el preu congelat a la línia perquè una comanda és història i no una vista, l'anonimització del client amb l'advertiment que aquest disseny el signa un jurista i no un programador, i els webhooks lliurats "com a mínim una vegada" perquè prometre més seria mentir—. I una autocrítica honesta: l'embolcall que es va quedar a mitges, el problem+json que hauríem hagut de fer servir, les ressenyes amb dos camins i l'estoc que avisava massa tard. Aquest és el material de què estan fetes les memòries tècniques útils: no la llista d'encerts, sinó la de bifurcacions amb el seu preu.

El risc d'una lliçó així és treure'n la conclusió equivocada: creure que ja tens la plantilla d'una API REST i que n'hi ha prou de canviar cafes pel que toqui. No és així. Cada decisió d'aquesta lliçó és correcta per a aquest domini: un catàleg petit i estable, estoc finit i transaccional, poques escriptures d'altíssim valor, cinc consumidors coneguts i consistència forta com a requisit irrenunciable. Canvia el domini i unes quantes d'aquestes decisions deixen de sostenir-se.

Això és exactament el que fa la lliçó següent. A 06-02, Cas d'estudi: API d'una xarxa social, dissenyem CafeSocial, la xarxa de tastadors que la Botiga Aroma vol llançar, i unes quantes certeses d'avui cauen una a una: el graf de seguidors obliga a modelar la relació com a recurs; la línia de temps no és una col·lecció normal sinó un recurs derivat amb el problema del fan-out al darrere; la paginació per desplaçament deixa de funcionar directament —duplicats i salts— i el cursor opac passa d'opció a obligació; els comptadors de "m'agrada" es tornen aproximats a propòsit; les imatges ja no es pugen per l'API; l'autorització deixa de ser "de qui és això" per convertir-se en "qui pregunta i què li deixem veure", amb 404 on la botiga responia 403; i el temps real deixa de ser un extra del tauler per ser el producte. Acabarem amb la taula que dona sentit als dos casos junts, decisió a decisió, i amb el motiu que en aquell domini GraphQL sigui una alternativa molt més defensable del que era aquí.

Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful

Mòdul 1: Introducció a les APIs RESTful

Mòdul 2: Disseny d'APIs RESTful

Mòdul 3: Desenvolupament d'APIs RESTful

Mòdul 4: Bones Pràctiques i Seguretat

Mòdul 5: Eines i Frameworks

Mòdul 6: Casos d'Estudi i Projectes

© Copyright 2026. Tots els drets reservats