A 06-02 vam comparar el disseny de la Botiga Aroma amb el de CafeSocial i vam tancar amb una tesi incòmoda: no existeix un disseny REST universal. Ara arriba l'últim assumpte, el que separa una API ben dissenyada d'una API que continua viva al cap de tres anys: què passa després de publicar-la. Perquè el dia del llançament l'API és teva; a partir de la primera integració és dels teus consumidors, i cada camp que retornes es converteix en una promesa. Aquesta lliçó recorre els tres primers anys de l'API de la Botiga Aroma com una línia temporal amb fites concretes: el primer incident, el post-mortem de la caiguda de Nadal, el deute de contracte que es va acumulant, la migració a v2 amb el seu calendari de dotze mesos i la feina silenciosa de manteniment que ningú no veu però que sosté tota la resta.

Contingut

  1. La línia temporal d'una API en producció
  2. El primer dia: què es vigila i què és normal
  3. Gestió d'incidents: gravetats, mitigació i comunicació
  4. El post-mortem sense culpables (document complet)
  5. Escoltar els consumidors
  6. El deute de contracte
  7. Afegir sense trencar: catàleg de canvis segurs
  8. Quan toca una v2 i com es migra en 12 mesos
  9. Retirar funcionalitats i el cost del que gairebé ningú no fa servir
  10. Manteniment continu: dependències, secrets, SLO i cost
  11. Inventari d'APIs i APIs zombis
  12. Govern amb diversos equips
  13. Errors comuns, exercicis i conclusió

  1. La línia temporal d'una API en producció

Convé veure el cicle complet abans d'entrar en cada fita. Aquests són els moments que van marcar els tres primers anys de https://api.botigaaroma.example/v1.

graph LR
    A["Mes 0<br/>Llancament v1<br/>SPA + tauler"] --> B["Mes 2<br/>Aroma Mobil<br/>1r incident S3"]
    B --> C["Mes 5<br/>RapidEnviaments<br/>webhooks HMAC"]
    C --> D["Mes 7<br/>Caiguda de Nadal<br/>34 min - post-mortem"]
    D --> E["Mes 11<br/>Registre de deute<br/>de contracte"]
    E --> F["Any 2 - mes 14<br/>CataBox OAuth<br/>+ analitica per client"]
    F --> G["Any 2 - mes 18<br/>Decisio: v2"]
    G --> H["Any 2 - mes 20<br/>Anunci + guia<br/>de migracio"]
    H --> I["Any 3 - mes 26<br/>Brownouts<br/>programats"]
    I --> J["Any 3 - mes 32<br/>Apagada v1<br/>410 retirada"]

Cap d'aquestes fites no és un projecte nou: totes són manteniment. I el manteniment consumeix, en una API amb diversos consumidors externs, bastant més esforç acumulat que la construcció inicial.


  1. El primer dia: què es vigila i què és normal

El mes 0 l'API es publica amb dos consumidors propis: la SPA https://botigaaroma.example i el tauler intern. Tot l'instrumental que vam muntar a 04-07 (pino per als logs estructurats, prom-client per a les mètriques, traces amb OpenTelemetry, /salut/viu i /salut/llest) deixa de ser un exercici i passa a ser l'únic lloc on es pot mirar.

Els quatre senyals que es vigilen des del minut u són els clàssics d'un servei de petició-resposta:

Senyal Què mesura Normal el mes 0 Alerta si
Taxa d'error 5xx error_intern, servei_no_disponible < 0,1 % de les peticions > 0,5 % durant 5 min
Latència p95 Temps de resposta per ruta 120–180 ms a GET /cafes > 400 ms durant 10 min
Saturació Connexions a SQLite, memòria, event loop Estable Lag de l'event loop > 100 ms
Trànsit Peticions per minut i per consumidor Creix de manera suau Salt x5 sense campanya coneguda

I hi ha un cinquè senyal que gairebé ningú no mira el primer dia i que resulta ser el més informatiu: la distribució dels 4xx.

# Top d'errors de client de les últimes 24 h, agrupats per codi del catàleg
# (els logs de pino surten en JSON, així que amb jq n'hi ha prou)
cat logs/api-*.log \
  | jq -r 'select(.res.statusCode >= 400 and .res.statusCode < 500)
           | "\(.res.statusCode) \(.error.codi // "sense_codi") \(.req.url | split("?")[0])"' \
  | sort | uniq -c | sort -rn | head -20

Sortida real del segon dia:

   412 400 parametre_invalid   /v1/cafes
   118 401 no_autenticat       /v1/comandes
    97 404 cafe_no_trobat      /v1/cafes/{id}
    31 409 conflicte_versio    /v1/cafes/{id}

Què és normal i què no. Els 401 són normals: la SPA reintenta amb el token caducat i el renova. Els 404 sobre /cafes/{id} també: hi ha enllaços antics indexats. El que no és normal són 412 parametre_invalid a GET /v1/cafes: això no és un client maldestre, és un contracte mal explicat. En inspeccionar els detalls de l'error apareix el patró: els clients envien ?torrefaccio=Mitja amb majúscula inicial, i el nostre enumerat només accepta clar|mitja|fosc. La documentació ho deia; el missatge d'error, no. Es va corregir el missatge —no la validació— i els 400 van baixar a 20 al dia.

Regla del primer dia: un error 4xx repetit no acusa el client, descriu un defecte de disseny o de documentació de la teva API.

El primer incident (mes 2). Amb el llançament d'Aroma Mòbil apareixen els primers 429. L'app fa polling de la cistella cada 5 segons i el rate limiting de 04-04 comença a retornar limit_peticions amb Retry-After. El diagnòstic va trigar vint minuts perquè les capçaleres Aroma-RateLimit-Restants no s'estaven registrant als logs. La solució no va ser apujar el límit: va ser afegir ETag a la cistella (ja el teníem a /cafes, vegeu 04-05) perquè el polling respongués 304 Not Modified a un cost gairebé nul, i publicar a la guia d'integració que l'interval recomanat era de 30 segons.


  1. Gestió d'incidents: gravetats, mitigació i comunicació

Detecció: el pressupost d'error mana

A 04-07 vam fixar un SLO de disponibilitat del 99,9 % mensual per a les rutes de lectura. Aquest 0,1 % són 43 minuts de pressupost d'error al mes. El pressupost és el que converteix una discussió d'opinions ("això és greu?") en una decisió aritmètica: si en tres dies s'ha consumit el 60 % del pressupost mensual, es congelen els desplegaments de funcionalitat i l'equip es dedica a la fiabilitat fins que la taxa es recuperi.

Les alertes no es disparen sobre llindars instantanis, sinó sobre velocitat de consum del pressupost (burn rate): consumir el pressupost 14 vegades més ràpid del que és sostenible durant 5 minuts és un avís immediat; 6 vegades més ràpid durant una hora, un avís normal.

Nivells de gravetat

Grav Definició Exemple a la Botiga Aroma Resposta Comunicació
S1 API caiguda o compres impossibles per a tothom POST /comandes/{id}/pagament retorna 500 al 100 % Guàrdia immediata, sala d'incident Pàgina d'estat en < 15 min + avís als socis
S2 Degradació greu o funcionalitat crítica trencada per a un consumidor Aroma Mòbil rep conflicte_versio a cada actualització Guàrdia immediata en horari ampliat Pàgina d'estat + correu al consumidor afectat
S3 Degradació parcial sense pèrdua de dades p95 de /cafes a 900 ms; cerques lentes Següent dia laborable Nota al portal de desenvolupador
S4 Defecte menor, contracte incomplert sense impacte Retry-After absent en un 429 concret Backlog prioritzat Changelog

Mitigar abans que diagnosticar

És la regla més difícil d'interioritzar per a un equip tècnic, perquè la curiositat empeny cap al perquè. Durant un incident, l'ordre correcte és:

  1. Restaurar el servei amb el que sigui: revertir l'últim desplegament, apagar la feature flag (05-04), tornar el trànsit al color anterior al blue-green, degradar una funcionalitat.
  2. Preservar evidències: capturar traces, Aroma-Traca-Id de peticions fallides, sortida d'EXPLAIN, mètriques de l'interval.
  3. Després, entendre la causa arrel amb calma.

Un desplegament revertit en 4 minuts costa un post-mortem; un desplegament depurat en calent durant 40 minuts costa el pressupost d'error del trimestre.

Què es comunica als consumidors

Amb consumidors externs —RàpidEnviaments i CataBox—, callar és pitjor que equivocar-se. El patró que vam adoptar: una entrada a la pàgina d'estat en menys de 15 minuts encara que no se'n sàpiga res ("estem investigant errors en la creació de comandes"), actualitzacions cada 30 minuts, i una nota final amb l'enllaç al post-mortem quan estigui publicat. Res de detalls interns d'infraestructura; sí l'impacte observable i el consell de reintent.


  1. El post-mortem sense culpables

Mes 7, 18 de desembre. La campanya de Nadal multiplica el trànsit per sis. Dues setmanes abans s'havia afegit el filtre ?origen= a GET /v1/cafes —un canvi retrocompatible de manual, tres línies de codi— però sense índex a la columna corresponent. Amb el catàleg crescut i el trànsit de campanya, la base de dades se satura i l'API queda inaccessible 34 minuts.

Aquest és el document tal com va quedar a docs/incidents/. Fixa't en el que no conté: cap nom associat a la causa.

# Post-mortem INC-041: saturació de la base de dades per un filtre sense índex

- **Estat:** tancat
- **Gravetat:** S1
- **Data:** 18 de desembre, 19:42 - 20:16 (CET)
- **Durada de l'impacte:** 34 minuts
- **Autor:** equip de plataforma d'API
- **Revisat per:** producte, suport, seguretat

## Resum

Un filtre afegit dues setmanes abans (`GET /v1/cafes?origen=`) provocava un escaneig
complet de la taula `cafes`. Amb el trànsit de la campanya de Nadal (x6 sobre la
mitjana) les consultes van esgotar el pool de connexions i tota l'API va deixar de
respondre, incloses rutes que no feien servir aquell filtre.

## Cronologia (hora CET)

| Hora | Esdeveniment |
|---|---|
| 04/12 10:15 | Es desplega el filtre `origen` a `/v1/cafes`. Sense índex. Sense prova de càrrega. |
| 18/12 19:31 | Comença l'enviament de la newsletter de Nadal amb enllaços a `?origen=etiopia`. |
| 18/12 19:38 | El p95 de `/v1/cafes` passa de 160 ms a 2,4 s. Ningú no ho mira. |
| 18/12 19:42 | Alerta de burn rate 14x. Comença l'impacte mesurable. |
| 18/12 19:44 | La guàrdia n'acusa recepció. S'obre la sala d'incident. |
| 18/12 19:47 | Es publica la primera nota a la pàgina d'estat ("investigant"). |
| 18/12 19:53 | Es descarta l'últim desplegament (del 17/12) com a causa: revertir-lo no canvia res. |
| 18/12 20:01 | S'identifica a les traces que el 88 % del temps se'n va en una única consulta. |
| 18/12 20:04 | **Mitigació:** es desactiva el filtre `origen` amb la feature flag i es retorna
                `400 parametre_invalid` temporalment per a aquell paràmetre. |
| 18/12 20:09 | La latència p95 torna a 210 ms. El pool es recupera. |
| 18/12 20:16 | Fi de l'impacte. Nota de resolució a la pàgina d'estat. |
| 18/12 21:30 | Es crea l'índex en una finestra de baixa càrrega i es reactiva el filtre. |

## Impacte mesurat

- 34 minuts d'indisponibilitat parcial-total (79 % de les peticions amb 5xx o timeout).
- 41.200 peticions fallides; 218 intents de `POST /v1/comandes` sense completar.
- 96 comandes no tancades durant la finestra; 61 es van recuperar soles per reintent
  del client gràcies a `Idempotency-Key` (no hi va haver cobraments duplicats).
- Pressupost d'error mensual consumit: 79 % (34 min sobre 43 min disponibles).
- 7 tiquets de suport i 1 avís de RàpidEnviaments per webhooks d'enviament endarrerits.

## Causa arrel

La consulta generada pel filtre `origen` no disposava d'índex i feia un escaneig
seqüencial de `cafes`. Sota concurrència alta, cada consulta mantenia la seva
connexió ocupada el temps suficient per esgotar el pool, de manera que peticions
alienes al filtre (`/v1/comandes`, `/v1/clients`) també quedaven en espera.

Causa contribuent: la revisió del canvi es va centrar en el contracte (nom del
paràmetre, validació, documentació a `openapi.yaml`) i no en el seu pla d'execució.
No existia cap comprovació automàtica que ho exigís.

## Què va fallar en la detecció

- La degradació va començar a les 19:38 i l'alerta va saltar a les 19:42: quatre minuts
  perduts perquè l'alerta de latència només mirava l'agregat global, no per ruta.
- No hi havia cap alerta sobre la saturació del pool de connexions, que era el senyal
  més primerenc i el més inequívoc.
- L'enviament de la newsletter no estava anunciat a l'equip de plataforma.

## Què vam fer bé

- La feature flag va permetre mitigar sense desplegar codi.
- La idempotència va evitar cobraments duplicats als reintents.
- La pàgina d'estat es va actualitzar abans de tenir diagnòstic.

## Accions

| # | Acció | Tipus | Responsable | Termini |
|---|---|---|---|---|
| 1 | Crear l'índex `idx_cafes_origen` i validar-lo amb EXPLAIN QUERY PLAN | Correctiva | Equip de dades | 19/12 (fet) |
| 2 | Alerta de latència p95 **per ruta**, no només agregada | Detecció | Plataforma | 09/01 |
| 3 | Alerta de saturació del pool de connexions al 80 % | Detecció | Plataforma | 09/01 |
| 4 | Afegir a la llista de revisió: "tot filtre nou declara el seu índex" | Preventiva | Govern d'API | 15/01 |
| 5 | Prova de càrrega automàtica a CI per a les rutes de llistat | Preventiva | Plataforma | 31/01 |
| 6 | Calendari compartit de campanyes de màrqueting amb plataforma | Organitzativa | Producte | 15/01 |
| 7 | Publicar el resum de l'incident al portal de desenvolupador | Comunicació | Suport | 22/12 (fet) |

## Què NO és una acció

"Tenir més cura en revisar" no és una acció: no és verificable ni deixa rastre.
Si una acció no es pot tancar amb un enllaç a un commit, a un tauler o a un
document, no entra en aquesta taula.

La verificació de l'acció 1, perquè quedi clar què es comprova:

-- Abans: escaneig complet de la taula
EXPLAIN QUERY PLAN
SELECT * FROM cafes WHERE origen = 'etiopia' ORDER BY nom LIMIT 20;
-- SCAN cafes

CREATE INDEX idx_cafes_origen ON cafes (origen, nom);

-- Després: cerca per índex
EXPLAIN QUERY PLAN
SELECT * FROM cafes WHERE origen = 'etiopia' ORDER BY nom LIMIT 20;
-- SEARCH cafes USING INDEX idx_cafes_origen (origen=?)

Sense culpables no vol dir sense responsables. Les accions tenen amo i termini; la causa no té amo. La persona que va afegir el filtre sense índex va fer el que el sistema li permetia fer: no hi havia llista de revisió, ni prova de càrrega, ni alerta. El sistema va fallar, i el sistema és el que s'arregla.


  1. Escoltar els consumidors

A partir del mes 9, la pregunta deixa de ser "funciona?" i passa a ser "què hi estan fent realment?". Tres fonts:

Analítica d'ús per endpoint i per client. Cada token JWT porta un client_id (04-02). S'etiqueta la mètrica de peticions amb aquest identificador, però amb compte amb la cardinalitat: etiquetar per ruta amb paràmetres (/v1/cafes/caf_001) genera una sèrie temporal per cafè i fa esclatar la memòria de Prometheus. S'etiqueta per plantilla de ruta i per client, que són conjunts petits i acotats.

// metriques/peticions.js — cardinalitat controlada a propòsit
import client from 'prom-client';

const peticions = new client.Counter({
  name: 'aroma_peticions_total',
  help: 'Peticions ateses per l\'API',
  // ruta = PLANTILLA (/v1/cafes/:id), mai la ruta concreta.
  // client = client_id del JWT, un conjunt tancat d'uns 6 consumidors.
  // versio = v1 | v2, imprescindible per a la migració (apartat 8).
  labelNames: ['metode', 'ruta', 'codi', 'client', 'versio'],
});

export function comptarPeticio(req, res) {
  peticions.inc({
    metode: req.method,
    ruta: req.route?.path ?? 'desconeguda',    // plantilla, no URL real
    codi: res.statusCode,
    client: req.auth?.clientId ?? 'anonim',    // etiqueta acotada
    versio: req.baseUrl.startsWith('/v2') ? 'v2' : 'v1',
  });
}

Quins camps no fa servir ningú. Una API REST retorna la representació completa, així que no saps què llegeix el client… tret que li ho preguntis. Dues tècniques barates: (a) mesurar l'ús del paràmetre de projecció si el tens (?camps=), i (b) preguntar-ho directament a l'enquesta anual als integradors. A la Botiga Aroma vam descobrir així que notesTast només el consumien la SPA i CataBox, i que el camp _links.self de cada element de col·lecció no el feia servir absolutament ningú: els clients construïen les URL per concatenació. Dada incòmoda que es va apuntar a l'autocrítica d'HATEOAS de 06-01.

Errors 4xx recurrents per client. És la millor llista de tasques de documentació que existeix:

Error repetit Client Interpretació real Acció presa
parametre_invalid a ordenar CataBox El separador -preuEuros no era als exemples Exemple afegit a openapi.yaml
conflicte_versio a PUT /cafes/{id} Tauler intern No reenviava l'ETag després d'una fallada Nota a la guia + detalls més explícits
estoc_insuficient al pagament Aroma Mòbil Avís d'estoc massa tardà Deute de disseny (vegeu l'apartat 6)
no_autenticat massiu a les 03:00 RàpidEnviaments Renovació de token mal programada Correu directe al soci

I el canal de suport: una adreça [email protected] que arriba a un humà, amb el compromís públic de respondre en 2 dies laborables. Cada tiquet s'etiqueta com a defecte, documentació o petició de funcionalitat; els de documentació es tanquen editant l'openapi.yaml, mai responent només per correu.


  1. El deute de contracte

Mes 11. L'equip fa una llista de "coses que arreglaríem si comencéssim avui" i s'adona que cap no es pot arreglar. Això és deute de contracte: decisions publicades que ja no són les millors, però que sostenen consumidors reals.

S'acumula per tres motius, i cap no és negligència:

  • El domini canvia. Quan es va dissenyar torrefaccio amb tres valors no existia la torrefacció filtre al catàleg.
  • L'estàndard canvia o es coneix millor. application/problem+json (RFC 9457) existia, però es va optar per un format propi; avui seria l'opció òbvia.
  • Encertes al 80 %. L'embolcall {"dades": [...], "total": n} va funcionar bé, però encareix cada resposta i confon els clients que esperen un array pelat.

A 06-01 ja vam criticar quatre decisions. Ara tenen preu:

Deute Canvi desitjat Per què no es pot fer a v1 Cost de conviure-hi
Nom poc clar notesTastnotesDeTast Trenca els 5 consumidors Baix: confon els nous
Embolcall Treure dades / fer servir només Link Trenca tot l'anàlisi de col·leccions Mitjà: codi duplicat als clients
Format d'error Adoptar problem+json Canvia el Content-Type i la forma del cos Mitjà: fricció amb biblioteques estàndard
Ressenyes amb dues rutes Deixar només /cafes/{id}/ressenyes La SPA fa servir /ressenyes?cafeId= Alt: dos camins per mantenir i cachejar
Avís tardà d'estoc Validar l'estoc en afegir a la cistella Canvia la semàntica de POST /cistelles/{id}/linies Alt: suport recurrent

L'artefacte que fa que això no s'oblidi és el registre de deute de contracte, versionat juntament amb el codi a docs/deute-contracte.yaml. És un document viu: es revisa cada trimestre i és la matèria primera de la decisió sobre la v2.

# docs/deute-contracte.yaml — es revisa cada trimestre
deutes:
  - id: DC-004
    titol: "L'avís d'estoc insuficient arriba al pagament, no a la cistella"
    origen: "Post-mortem INC-041 i 38 tiquets de suport"
    consumidors_afectats: [spa-botiga, aroma-mobil]
    impacte: alt
    solucio_desitjada: >
      Validar l'estoc a POST /cistelles/{id}/linies i retornar 409 estoc_insuficient
      en aquell moment, mantenint la validació final al pagament.
    trenca_contracte: true   # canvia el codi d'estat en un cas abans vàlid
    candidata_v2: true
    creada: "any 1, mes 11"
    revisada: "any 2, mes 18"

  - id: DC-007
    titol: "Format d'error propi en lloc d'application/problem+json"
    consumidors_afectats: [tots]
    impacte: mitja
    trenca_contracte: true
    candidata_v2: true
    nota: >
      Mitigació possible sense trencar: negociació de contingut. Si el client envia
      Accept: application/problem+json retornem aquell format; si no, el propi.

Fixa't en la nota de DC-007: part del deute es pot pagar sense trencar res si es pensa en termes de negociació de contingut (02-05). No tot deute exigeix una versió nova.


  1. Afegir sense trencar: catàleg de canvis segurs

Abans de plantejar-se una v2, cal esgotar el que es pot fer dins de v1. La regla general la vam veure a 02-07: afegir és segur, treure i canviar el significat no ho és. El detall importa molt més del que sembla.

Canvi Trenca? Solució retrocompatible
Camp nou origenCertificat a Cafe No* Afegir-lo opcional i documentar-lo; els clients que no el coneixen l'ignoren
Endpoint nou /cafes/{id}/lots No Publicar i documentar
Filtre nou ?certificat=true No Opcional, amb valor per defecte = comportament actual
Valor nou filtre a l'enumerat torrefaccio (resposta) Sí, a la pràctica Introduir-lo amb avís previ; els clients amb switch exhaustiu fallen
Valor nou acceptat a torrefaccio (petició) No Ampliar la validació d'entrada és segur
Endurir una validació existent Fase d'avís: registrar, no rebutjar; després rebutjar
Canviar el valor per defecte de limit de 20 a 50 No canviar-lo; o canviar-lo només per als clients nous
Reanomenar notesTast Duplicar el camp + deprecar el vell, o esperar la v2
Treure un camp Només a la v2
Canviar 200 per 202 en una operació Només a la v2
Fer obligatori un camp d'entrada opcional Només a la v2

* L'asterisc del camp nou. "Afegir un camp no trenca res" només és cert si els clients són tolerant readers: si ignoren el que no coneixen. Un client que validi la resposta contra un esquema estricte amb additionalProperties: false, o que faci servir un llenguatge que falli en deserialitzar camps desconeguts, es trenca amb un camp nou. Per això la guia d'integració de la Botiga Aroma diu això a la primera pàgina:

// Contracte de tolerància publicat a la guia d'integració.
// Així ha de llegir un client la resposta de GET /v1/cafes/caf_001

const cafe = await resposta.json();

// BÉ: es llegeixen els camps coneguts i s'ignora la resta.
const vista = {
  id: cafe.id,
  nom: cafe.nom,
  preuEuros: cafe.preuEuros,
  // Valor desconegut en un enumerat: es degrada, no peta.
  torrefaccio: ['clar', 'mitja', 'fosc'].includes(cafe.torrefaccio) ? cafe.torrefaccio : 'altre',
};

// MALAMENT: es trenca tan bon punt afegim origenCertificat.
// const { id, nom, preuEuros, ...resta } = cafe;
// if (Object.keys(resta).length > 0) throw new Error('Camp desconegut');

El cas perillós: endurir una validació. El mes 14 es va descobrir que notesTast admetia textos de qualsevol longitud i algú hi havia desat 40 KB. La temptació és afegir .max(500) a l'esquema Zod (03-02) i desplegar. Això converteix peticions abans vàlides en 422 dades_invalides: és un canvi trencador encara que no toqui cap nom de camp. El procediment correcte és en dos temps.

// Fase 1 (setmanes 1-6): mode avís. No es rebutja res, es mesura qui l'incompliria.
const esquemaCafe = z.object({
  nom: z.string().min(1).max(120),
  notesTast: z.string(),   // encara sense límit
  // ...
}).superRefine((dades, ctx) => {
  if (dades.notesTast.length > 500) {
    // Es registra amb el client per poder avisar-lo un a un.
    log.warn({
      esdeveniment: 'validacio_futura_incomplerta',
      regla: 'notesTast_max_500',
      longitud: dades.notesTast.length,
      client: ctx.path,
    }, "Petició que serà rebutjada a partir de l'1 de març");
    comptadorAvisos.inc({ regla: 'notesTast_max_500' });
  }
});

// Fase 2 (setmana 7, només si el comptador està a zero durant 14 dies):
// notesTast: z.string().max(500)

Si al cap de sis setmanes el comptador continua pujant, no es desplega la fase 2: es truca al client. La data es mou; el contracte no es trenca per sorpresa.


  1. Quan toca una v2 i com es migra en 12 mesos

La resposta correcta gairebé sempre és "encara no"

Una v2 no és una fita assolida, és una factura: dues bases de codi o dues capes de traducció, dos jocs de proves, dues documentacions, dues versions de l'openapi.yaml, i consumidors que trigaran mesos a moure's. Criteris honestos:

Senyals que NO toca v2:

  • El canvi es pot fer afegint (apartat 7).
  • Molesta l'equip però no els consumidors (notesTast per si sol no justifica res).
  • És un problema de documentació disfressat de problema de disseny.
  • Hi ha menys de tres deutes d'impacte alt acumulats.

Senyals que SÍ que toca:

  • Diversos deutes d'impacte alt que només es resolen trencant, i que causen incidents o suport recurrent.
  • El model de domini ha canviat de debò (la Botiga Aroma va passar a vendre subscripcions, i una comanda recurrent no encaixa en el recurs /comandes actual).
  • Canvis de seguretat no negociables.
  • El cost de mantenir els pedaços supera el cost de migrar.

Mes 18. La Botiga Aroma decideix la v2 amb quatre deutes d'impacte alt i un domini nou (subscripcions). S'escriu un ADR a docs/decisions/0031-llancar-v2.md amb l'alternativa descartada (continuar apedaçant la v1 amb problem+json negociat i un recurs /subscripcions penjat de la v1) i per què es rebutja.

Calendari de 12 mesos

Recordem la política publicada: versionat només a la ruta, i v1 i v2 conviuen com a mínim 6 mesos. A la pràctica, per a una API amb socis externs, sis mesos és el mínim legal i dotze el mínim raonable.

Mes Fita Què veu el consumidor
M0 Anunci + guia de migració + v2 en beta Correu, portal, changelog, entorn de proves
M1 v2 estable en producció Totes dues versions funcionant
M1 v1 marcada com a deprecada Capçaleres Deprecation, Sunset, Link rel="successor-version"
M2–M5 Acompanyament Suport prioritari als integradors, exemples, sessions tècniques
M6 Primer tall de mètriques Informe intern: qui continua a v1
M7 Contacte directe amb els ressagats Trucada, no correu automàtic
M9 Brownout 1: 30 min de 410 a v1 Avís 2 setmanes abans; finestra de baixa càrrega
M10 Brownout 2: 2 h de 410 Avís 2 setmanes abans
M11 Brownout 3: 8 h de 410 Avís 2 setmanes abans
M12 Apagada definitiva de v1 410 Gone permanent amb versio_api_retirada
M12+3 Esborrat del codi de v1

Guia de migració: la taula d'equivalències

És el document que decideix si la migració és de dos dies o de dos mesos per al consumidor. Res de prosa: equivalències literals.

v1 v2 Nota
GET /v1/cafes{"dades":[…],"total":n} GET /v2/cafes{"items":[…],"paginacio":{…}} El total exacte passa a ser opcional
notesTast notesDeTast Reanomenat
torrefaccio: clar|mitja|fosc torrefaccio: clar|mitja|fosc|filtre Valor nou
Error propi {"error":{…}} application/problem+json coditype (URI del catàleg)
GET /v1/ressenyes?cafeId= GET /v2/cafes/{id}/ressenyes Ruta única
POST /v1/cistelles/{id}/linies (sense control d'estoc) Igual, però pot retornar 409 estoc_insuficient Avís primerenc
GET /v2/subscripcions Recurs nou
?desplacament= ?cursor= desplacament s'accepta 6 mesos a v2

Les capçaleres de deprecació en funcionament

Reprenent 02-07, així respon v1 a partir del mes 1:

GET /v1/cafes?origen=etiopia HTTP/1.1
Host: api.botigaaroma.example
Authorization: Bearer eyJhbGciOi...

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Deprecation: @1836345600
Sunset: Sat, 12 Jun 2027 00:00:00 GMT
Link: <https://api.botigaaroma.example/v2/cafes?origen=etiopia>; rel="successor-version",
      <https://docs.botigaaroma.example/migracio-v2>; rel="deprecation"; type="text/html"
Aroma-Traca-Id: trz_9f2a41c8
// middleware/deprecacio.js — s'aplica a tot l'enrutador de /v1
const SUNSET = new Date('2027-06-12T00:00:00Z');
const DEPRECATION_UNIX = Math.floor(new Date('2026-06-12T00:00:00Z').getTime() / 1000);

export function deprecarV1(req, res, seguent) {
  // Deprecation: quan es va DECLARAR obsoleta (format IMF-data o marca @unix).
  res.set('Deprecation', `@${DEPRECATION_UNIX}`);
  // Sunset: quan deixarà de respondre. És una promesa; no s'avança mai.
  res.set('Sunset', SUNSET.toUTCString());
  res.append('Link',
    `<https://api.botigaaroma.example/v2${req.path}>; rel="successor-version"`);
  res.append('Link',
    '<https://docs.botigaaroma.example/migracio-v2>; rel="deprecation"; type="text/html"');
  seguent();
}

Saber qui continua a v1

Amb l'etiqueta versio del comptador de l'apartat 5, la pregunta es respon sola:

# Consumidors que encara fan servir v1 els últims 7 dies, ordenats per volum
curl -sG "http://prometheus.intern:9090/api/v1/query" \
  --data-urlencode 'query=topk(10, sum by (client) (increase(aroma_peticions_total{versio="v1"}[7d])))' \
  | jq -r '.data.result[] | "\(.metric.client)\t\(.value[1] | tonumber | floor)"'
catabox            412803
integracio-erp      18744   <- ningu sabia que existia (vegeu apartat 11)
aroma-mobil          2210   <- versio antiga de l'app, sense actualitzar

Aquest segon resultat és el motiu pel qual no s'apaga una versió per calendari sense mirar les mètriques: sempre apareix un consumidor oblidat. I el tercer recorda que a les apps mòbils no controles quan actualitza l'usuari: l'app antiga continuarà viva durant mesos.

Brownouts

Un brownout és una apagada breu programada i anunciada de v1: durant la finestra, totes les peticions reben 410. La seva funció no és tècnica, és psicològica: converteix una data llunyana en un incident real a l'entorn del consumidor, que és l'única cosa que mou prioritats.

// middleware/brownout.js — apagades breus programades de v1
const FINESTRES = [
  { des: '2027-03-10T09:00:00Z', fins: '2027-03-10T09:30:00Z' }, // 30 min
  { des: '2027-04-14T09:00:00Z', fins: '2027-04-14T11:00:00Z' }, // 2 h
  { des: '2027-05-12T07:00:00Z', fins: '2027-05-12T15:00:00Z' }, // 8 h
];

export function brownoutV1(req, res, seguent) {
  const ara = Date.now();
  const finestra = FINESTRES.find(f =>
    ara >= Date.parse(f.des) && ara < Date.parse(f.fins));

  if (!finestra) return seguent();

  // Retry-After indica quan torna v1: durant el brownout SÍ que torna.
  res.set('Retry-After', String(Math.ceil((Date.parse(finestra.fins) - ara) / 1000)));
  res.status(410).json({
    error: {
      codi: 'versio_api_retirada',
      missatge: 'Apagada programada de /v1. Migra a /v2 abans del 12/06/2027.',
      detalls: [{ camp: 'versio', valor: 'v1', successor: '/v2' }],
    },
  });
}

I l'apagada definitiva, ja sense Retry-After:

GET /v1/cafes HTTP/1.1
Host: api.botigaaroma.example

HTTP/1.1 410 Gone
Content-Type: application/json; charset=utf-8
Link: <https://api.botigaaroma.example/v2/cafes>; rel="successor-version"

{
  "error": {
    "codi": "versio_api_retirada",
    "missatge": "La versió v1 es va retirar el 12/06/2027. Fes servir /v2.",
    "detalls": [
      { "camp": "versio", "valor": "v1" },
      { "camp": "guia", "valor": "https://docs.botigaaroma.example/migracio-v2" }
    ]
  }
}

410 Gone i no 404: la diferència comunica que el recurs va existir i va desaparèixer a propòsit, i evita que un client cregui que s'ha equivocat de ruta.


  1. Retirar funcionalitats i el cost del que gairebé ningú no fa servir

Any 2. L'endpoint GET /v1/cafes/{id}/maridatges, afegit el mes 4 per petició de màrqueting, rep 40 peticions al mes d'un únic client. El seu cost no és zero:

  • Apareix a openapi.yaml, així que cal documentar-lo i revisar-lo amb Spectral.
  • Té proves que s'executen a cada CI i que de vegades fallen per dades de prova.
  • Té una taula amb la seva migració, que cal arrossegar a cada canvi d'esquema.
  • Bloqueja decisions: qualsevol refactorització del recurs Cafe l'ha de contemplar.
  • Ocupa espai mental a cada revisió de disseny.

La retirada d'una funcionalitat concreta segueix el mateix protocol que una versió, en petit: anunci, Deprecation/Sunset només en aquella ruta, contacte amb l'únic consumidor, i 410. Va durar tres mesos. El que no s'ha de fer mai és esborrar-lo perquè "gairebé ningú no el fa servir": aquest "gairebé" és una empresa que en depèn.


  1. Manteniment continu: el que ningú no veu

Feina recurrent que no produeix funcionalitats i sense la qual l'API es degrada sola.

Dependències i CVE. Auditoria setmanal automatitzada al ci.yml de 05-04:

npm audit --audit-level=high            # falla el pipeline si n'hi ha d'alta o crítica
npm outdated                            # informe setmanal, no bloquejant
docker scout cves botigaaroma-api:latest

La política acordada: vulnerabilitat crítica en una dependència explotable, pedaç en 48 h; alta, en 7 dies; la resta, a la finestra mensual de manteniment.

Versió de Node. Node 20 entra en fi de suport i cal saltar a la LTS següent. És un canvi invisible per al consumidor i perillós per a tu: es fa per canary (05-04), amb el 5 % del trànsit durant 48 h, comparant p95 i taxa d'error entre tots dos grups. S'apunta al calendari abans que el suport expiri, no després.

Rotació de secrets i claus de signatura. Tres rellotges diferents:

Secret Rotació Com es rota sense tallar
Clau de signatura JWT Cada 90 dies Dues claus actives amb kid; se signa amb la nova, es verifiquen totes dues
Secret HMAC dels webhooks a RàpidEnviaments Cada 180 dies Signatura doble durant 14 dies (Aroma-Signatura i Aroma-Signatura-Seguent)
Credencials d'OAuth de CataBox Anual o davant d'un incident Solapament de 30 dies

Revisió dels SLO. Cada semestre. Si l'SLO del 99,9 % no s'ha fregat mai en un any, o està mal mesurat o és massa laxe. Si s'incompleix cada mes, o no és assolible amb l'arquitectura actual, o el pressupost d'error s'està fent servir com a excusa. A la Botiga Aroma, l'SLO de latència de /cafes es va endurir de 400 ms a 300 ms l'any 2 després de la feina d'índexs.

Revisió de seguretat. Repàs anual de l'OWASP API Top 10 (04-01) contra l'estat real: autorització a nivell d'objecte a cada endpoint nou, límits de consum, exposició de dades. Es documenta com qualsevol revisió de disseny.

El cost d'infraestructura com a senyal de disseny. La factura de l'API és una mètrica de disseny disfressada de mètrica financera. Si un endpoint costa desproporcionadament, gairebé sempre hi ha una decisió de contracte al darrere: GET /v1/comandes sense paginació obligatòria retornant milers d'elements, absència d'ETag en un recurs molt consultat, o un client fent polling on hi hauria d'haver un webhook. Abans d'escalar la infraestructura, revisa el contracte: surt més barat.


  1. Inventari d'APIs i APIs zombis

A 04-02 vam definir la propietat de cada API i a 05-06 vam muntar el portal de desenvolupador amb el seu inventari. La seva utilitat real apareix just ara: a la migració a v2 va aparèixer un consumidor anomenat integracio-erp que ningú no recordava haver autoritzat. Existia, funcionava i era en producció.

Una API zombi és una API (o una versió, o un endpoint) que continua responent, consumeix recursos i presenta superfície d'atac, però no té amo identificable. Es detecten creuant tres fonts: l'inventari declarat, les mètriques reals de trànsit i el registre de credencials emeses. Qualsevol fila que aparegui en una font i falti en una altra és una alarma.

Cada entrada de l'inventari ha de respondre, com a mínim: qui la manté, qui la consumeix, quina versió està vigent, quina data de retirada té si està deprecada, i on és el seu openapi.yaml. Sense amo no es desplega: és l'única manera que l'inventari no envelleixi.


  1. Govern amb diversos equips

Any 3. Ja no hi ha un equip, n'hi ha tres: catàleg, comandes i subscripcions. Sense govern, en sis mesos tens tres APIs que semblen de tres empreses diferents: una amb snake_case, una altra amb errors en text pla, una altra paginant amb page/size.

El govern de la Botiga Aroma es recolza en quatre peces, totes ja conegudes:

  • La guia d'estil (02-01) com a norma escrita, no com a recomanació. És la referència que tanca discussions en una revisió.
  • La revisió de disseny: abans d'escriure codi, l'equip presenta el fragment d'openapi.yaml proposat. Deu minuts amb dues persones d'altres equips eviten mesos de deute. Es revisa el contracte, no la implementació.
  • Portes automàtiques a CI (05-04/05-05): Spectral valida l'estil i oasdiff detecta canvis trencadors. El que és automàtic no es discuteix, i això és precisament la seva virtut.
  • ADR a docs/decisions/: cada decisió estructural amb el seu context, alternatives i conseqüències. Serveix perquè d'aquí a dos anys ningú no "arregli" alguna cosa que era així a propòsit.
# .github/workflows/ci.yml (extracte) — portes de contracte
  contracte:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }

      - name: Estil del contracte (guia d'estil com a codi)
        run: npx @stoplight/spectral-cli lint openapi.yaml --fail-severity=warn

      - name: Canvis trencadors respecte de la versió publicada
        run: |
          git show origin/master:openapi.yaml > /tmp/openapi-publicat.yaml
          npx oasdiff breaking /tmp/openapi-publicat.yaml openapi.yaml --fail-on ERR

      # Si el canvi ÉS trencador a propòsit, s'aprova amb l'etiqueta
      # "canvi-trencador-aprovat" al PR i un ADR enllaçat. Mai en silenci.

I el flux humà complet d'un canvi de contracte:

sequenceDiagram
    participant E as Equip
    participant R as Revisio de disseny
    participant CI as CI (Spectral + oasdiff)
    participant C as Consumidors
    E->>R: Proposta de canvi a openapi.yaml
    R-->>E: Guia d'estil, ADR si es estructural
    E->>CI: Pull request
    CI-->>E: Trencador detectat
    alt No trencador
        CI->>C: Desplegament canary + changelog
    else Trencador
        E->>R: ADR amb alternatives
        R-->>E: Al deute de contracte o a v2
    end
    C-->>E: Metriques d'us i suport

Errors Comuns i Consells

  • Depurar en calent en lloc de mitigar. Cada minut de diagnòstic durant un S1 es paga amb pressupost d'error. Reverteix, apaga la flag, i després investiga.
  • Post-mortems que acaben en "manca d'atenció". Si l'acció no es pot tancar amb un enllaç verificable, no és una acció. I si el document anomena una persona com a causa, la propera vegada ningú no explicarà què va passar.
  • Confondre "sense culpables" amb "sense conseqüències". Les accions tenen responsable i termini, i es revisen a la retrospectiva del mes següent.
  • Creure que afegir un camp no trenca mai. Només és cert amb clients tolerant readers. Publica aquest requisit a la guia d'integració des del primer dia.
  • Endurir validacions sense fase d'avís. És el canvi trencador que més s'escola perquè no toca l'esquema visible. Mesura primer, rebutja després.
  • Etiquetar mètriques per ruta concreta o per id. La cardinalitat esclata i et quedes sense observabilitat justament quan la necessites. Plantilles de ruta i conjunts tancats.
  • Apagar v1 per calendari sense mirar les mètriques per versió. Sempre apareix un consumidor oblidat. Les mètriques manen sobre el calendari; el Sunset anunciat no s'avança, però sí que es pot endarrerir.
  • Llançar una v2 per incomoditat interna. Si el dolor el pateix només el teu equip, resol-lo per dins. Una versió nova és una factura que paguen tots els teus consumidors.
  • Tractar els 4xx com a culpa del client. Són la teva llista de tasques de documentació ordenada per impacte.
  • Consell final: publica el calendari de deprecació en un lloc estable i respecta'l encara que faci mal. Un Sunset que s'avança destrueix més confiança que un incident de 34 minuts.

Exercicis

Exercici 1: classificar canvis

Per a cada canvi proposat sobre la v1 de la Botiga Aroma, indica si és retrocompatible, si és trencador, i quina seria l'estratègia correcta:

  1. Afegir el camp origenCertificat (booleà) a la representació de Cafe.
  2. Afegir el valor filtre a l'enumerat torrefaccio a les respostes.
  3. Canviar el limit per defecte de GET /v1/cafes de 20 a 50.
  4. Començar a rebutjar preuMin negatiu amb 422 dades_invalides.
  5. Afegir el filtre opcional ?certificat=true.

Exercici 2: decidir sobre la v2

Una API interna de facturació acumula aquests deutes: (a) el camp total és en euros com a nombre decimal i provoca errors d'arrodoniment; (b) GET /factures no pagina i retorna fins a 4.000 elements; (c) el nom factura_id fa servir snake_case mentre la resta de l'API fa servir camelCase. Només hi ha dos consumidors, tots dos interns. Llançaries una v2? Justifica-ho amb criteris, no amb gustos.

Exercici 3: accions d'un post-mortem

Un desplegament del divendres a la tarda va introduir una fallada en la renovació de tokens: durant 18 minuts, totes les peticions d'Aroma Mòbil van rebre 401 no_autenticat. Es va detectar perquè un usuari ho va escriure a les xarxes socials; ningú de l'equip no va veure cap alerta. Escriu cinc accions per al post-mortem, cadascuna amb tipus (correctiva, detecció, preventiva, organitzativa, comunicació) i un criteri de tancament verificable.


Solucions

Exercici 1

# Canvi Veredicte Estratègia
1 origenCertificat Retrocompatible* Afegir-lo opcional i documentar-lo. L'asterisc: si algun consumidor valida amb additionalProperties: false, sí que trenca. Comprovar-ho abans a la guia d'integració i avisar al changelog
2 Valor filtre a les respostes Trencador a la pràctica Qualsevol client amb un switch exhaustiu sobre torrefaccio fallarà. Anunciar-ho amb 30 dies, documentar el valor nou, i primer acceptar-lo a les peticions (segur) abans d'emetre'l a les respostes
3 limit per defecte 20 → 50 Trencador Canvia la mida de pàgina que el client rep sense demanar-la; pot desbordar interfícies i multiplicar la càrrega. No canviar-lo a v1. Alternativa: documentar millor limit i deixar el valor per defecte per a la v2
4 Rebutjar preuMin negatiu Trencador Encara que sigui "més correcte", peticions abans acceptades comencen a fallar. Fase d'avís mesurant amb superRefine durant 6 setmanes; si el comptador arriba a zero, rebutjar. I 400 parametre_invalid, que és un paràmetre de consulta, no un cos
5 Filtre ?certificat=true Retrocompatible Opcional; absent = comportament actual. Afegir-lo a openapi.yaml, amb el seu índex a la base de dades (lliçó de l'INC-041)

Exercici 2

No, encara no. Anàlisi per deute:

  • (a) total decimal en euros. És real i greu (arrodoniment en diners), però es resol afegint: publicar totalCentims com a camp nou, documentar-lo com a preferent, deprecar total a la documentació i mesurar-ne l'ús. No requereix versió.
  • (b) GET /factures sense paginació. Es pot paginar de manera retrocompatible: acceptar limit/desplacament, i mentre no s'enviïn, retornar el comportament actual. Amb dos consumidors interns, a més es pot negociar un límit màxim amb avís previ. No requereix versió.
  • (c) factura_id en snake_case. És incomoditat estètica. Solució: emetre també facturaId (tots dos camps conviuen), documentar el nou, i eliminar el vell quan les mètriques d'ús ho permetin o arribi una v2 motivada per una altra cosa.

A més, amb dos consumidors interns el cost de coordinació és baixíssim comparat amb el de mantenir dues versions. Criteri aplicable: cap dels tres deutes no obliga a trencar, per tant no hi ha v2. El que sí que escau és obrir tres entrades al registre de deute de contracte amb candidata_v2: true i revisar-les cada trimestre.

Exercici 3

# Acció Tipus Criteri de tancament
1 Corregir la fallada de renovació de tokens i desplegar amb canary al 5 % Correctiva Commit enllaçat + 24 h de canary amb la taxa de 401 a la línia base
2 Alerta sobre la taxa de 401 per client, amb dispar als 3 min per damunt del doble de la base Detecció Alerta creada i provada amb una injecció controlada a preproducció
3 Prova d'integració del cicle complet de renovació (token caducat → refresc → petició) a CI Preventiva Prova en node:test + Supertest executant-se a ci.yml i fallant si es reverteix l'arreglament
4 Política de congelació de desplegaments els divendres a partir de les 15:00 tret de correccions urgents Organitzativa Regla publicada al repositori i comprovació automàtica al pipeline
5 Nota a la pàgina d'estat i avís als usuaris afectats d'Aroma Mòbil amb el resum de l'incident Comunicació Entrada publicada amb enllaç al post-mortem

Observa que l'acció 2 és la més valuosa: la fallada va durar 18 minuts, però el problema real és que la detecció va arribar des de fora. Un incident que descobreix un usuari a les xarxes socials és, abans de res, una fallada d'observabilitat.


Conclusió

Dissenyar una API és un exercici acotat; mantenir-la és un compromís indefinit. En aquesta lliçó hem recorregut tres anys de l'API de la Botiga Aroma i hem vist que gairebé tota la feina posterior al llançament consisteix a protegir qui ja confia en tu: vigilar els senyals adequats des del primer dia, mitigar abans de diagnosticar quan alguna cosa es trenca, escriure post-mortems que arreglin el sistema en lloc de buscar responsables, escoltar el que els 4xx i l'analítica d'ús t'estan dient, anotar el deute de contracte en lloc de fingir que no existeix, esgotar tot el que es pot afegir sense trencar, i —només quan ja no queda alternativa— planificar una v2 amb el seu calendari, les seves capçaleres Deprecation i Sunset, els seus brownouts i el seu 410 final. A això s'hi suma el manteniment que ningú no aplaudeix: CVE, versions de Node, rotació de claus, revisió dels SLO, inventari sense zombis i un govern amb Spectral, oasdiff i ADR que mantingui la coherència quan ja no hi ha un sol equip.

La conclusió de fons és senzilla i exigent alhora: una API és un compromís a llarg termini amb qui la consumeix. Cada camp publicat és una promesa, cada Sunset anunciat és un contracte, i la confiança que construeixes durant tres anys es pot perdre amb un canvi "innocu" desplegat un divendres a la tarda. La qualitat d'una API no es mesura el dia del llançament, sinó en la facilitat amb què un integrador continua treballant-hi quan ja no queda ningú de l'equip original a l'empresa.

Amb això tanquem el recorregut de casos pràctics i anàlisi. Et toca a tu: a 06-04, Projecte final: Dissenyar i desenvolupar la teva pròpia API RESTful, aplicaràs per fases tot el que has après —disseny del contracte, implementació, seguretat, documentació i desplegament— sobre un domini propi, amb una rúbrica clara per autoavaluar-te. Tot el que hem fet fins ara estava preparant aquest encàrrec.

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