Tot el que hem construït en aquest mòdul (els contractes REST de 03-01, el sobre d'esdeveniment i les càrregues de 03-02, el .proto d'Inventari i l'esquema GraphQL de 03-03) té una cosa en comú: són contractes entre equips que evolucionaran. L'equip d'Experiència de compra voldrà afegir atributs als productes; el de Comandes voldrà un camp nou a comanda.creada; algú decidirà que preu com a número solt va ser un error i que hauria de portar moneda. Cadascun d'aquests canvis pot trencar silenciosament un consumidor que ningú no ha avisat. A 02-01 vam dir que l'únic acoblament acceptable entre serveis és el de contracte; aquesta lliçó explica com gestionar aquest acoblament perquè un canvi en un servei no es converteixi en un incident en un altre.

Veurem el contracte com a frontera entre equips, quins canvis són compatibles i quins no, la regla de tolerància (Postel) que evita la majoria de trencaments, les estratègies de versionat REST amb l'elecció de TechCorp, el cicle de vida d'una versió (deprecació, convivència, retirada), el versionat d'esdeveniments (el camp versio del sobre, evolució compatible, upcasting), el de Protobuf i GraphQL, l'enfocament design-first amb OpenAPI i AsyncAPI, una introducció conceptual als tests de contracte dirigits pel consumidor, i un exemple guiat complet: fer evolucionar GET /productes primer de manera compatible i després incompatible, amb el seu període de convivència i el pla de migració de Comandes. La implementació dels tests de contracte amb Pact és de 04-05.

Contingut

  1. El contracte com a frontera entre equips
  2. Canvis compatibles i incompatibles
  3. La regla de tolerància: consumidor tolerant, productor conservador
  4. Estratègies de versionat REST i l'elecció de TechCorp
  5. Cicle de vida d'una versió
  6. Versionat d'esdeveniments
  7. Versionat de Protobuf i de GraphQL
  8. Contracte primer: OpenAPI i AsyncAPI
  9. Tests de contracte dirigits pel consumidor (introducció)
  10. Exemple guiat: fer evolucionar GET /productes

  1. El contracte com a frontera entre equips

Un contracte és tot allò de què un consumidor pot dependre legítimament: la forma de la petició i la resposta, els codis d'estat, els camps i els seus tipus, la semàntica de cadascun, els codis d'error (codi), les capçaleres, l'ordre (o no) dels esdeveniments, els valors d'un enum. El que no és contracte: la implementació, la base de dades, l'ordre de les claus al JSON, el text de detail, els camps no documentats.

En microserveis el contracte és l'única superfície de contacte entre equips, i per això concentra dues tensions oposades:

  • El productor (Catàleg) vol canviar la seva API quan el seu negoci canvia, sense demanar permís.
  • Els consumidors (Comandes, el BFF mòbil, l'ERP d'un soci) volen que res no canviï sense avisar, perquè cada canvi és feina i risc per a ells.

Del mapa de contextos de 02-03 surt qui mana en cada relació: en un open host service amb published language (Catàleg → Comandes), el productor publica i els consumidors s'adapten, però amb regles d'evolució que aquesta lliçó fixa; en customer-supplier (Clients → Comandes), el consumidor té veu en el contracte; en partnership (Comandes ↔ Inventari), el negocien plegats. En tots els casos, la disciplina és la mateixa: els canvis compatibles es fan lliurement; els incompatibles requereixen una versió nova i un període de convivència.

  1. Canvis compatibles i incompatibles

Un canvi és compatible cap enrere si un consumidor escrit contra la versió anterior continua funcionant sense tocar-lo. És l'única propietat que importa.

Canvi Compatible? Per què Exemple a TechCorp
Afegir un camp opcional a la resposta (si el consumidor ignora el que desconeix) El consumidor vell no el llegeix Afegir atributs a GET /productes
Afegir un camp opcional a la petició El consumidor vell no l'envia i el servidor assumeix el valor per defecte POST /comandes accepta notes opcional
Afegir un endpoint o un mètode nou Ningú no el feia servir POST /comandes/{id}/cancellacio
Afegir un valor a un enum a la resposta Depèn (formalment incompatible) Un consumidor amb switch exhaustiu sobre estats es trenca amb un valor nou Afegir EN_REPARTIMENT als estats de la comanda: el front que mapa estats a textos mostra undefined
Afegir un esdeveniment nou Ningú no hi està subscrit comanda.enviada
Canviar el nom d'un camp No El consumidor llegeix el nom vell i obté undefined preupreuUnitari
Canviar el tipus d'un camp No preu: 59.90preu: {import, moneda} trenca qualsevol total += preu L'exemple de l'apartat 10
Eliminar un camp o un endpoint No El consumidor el necessita Treure disponible de GET /productes
Fer obligatori un camp opcional de la petició No Peticions velles vàlides comencen a donar 400 Exigir pais a adrecaEnviament
Fer opcional (anul·lable) un camp que era obligatori a la resposta No El consumidor assumeix que sempre ve nom passa a poder ser null
Canviar la semàntica sense canviar la forma No, i és el pitjor Res no falla en temps de compilació ni a les proves; falla el negoci preu passa de "sense IVA" a "amb IVA"; total deixa d'incloure l'enviament
Canviar un codi d'estat o un codi d'error No Els clients fan switch sobre ells SENSE_ESTOC de 409 a 422
Canviar la URL d'un recurs No Els enllaços desats i el codi dels clients apunten a la vella /productes/articles
Endurir una validació No Peticions que abans passaven ara fallen Baixar el màxim de línies de 100 a 50
Relaxar una validació Res que passava deixa de passar Pujar el màxim de 50 a 100

Regla mnemotècnica: afegir és (gairebé sempre) compatible; treure, canviar el nom, canviar de tipus o de significat no ho és. I el canvi de semàntica mereix una atenció especial perquè no el detecta cap eina: només la comunicació entre equips i els tests de contracte.

  1. La regla de tolerància: consumidor tolerant, productor conservador

El principi de robustesa (o llei de Postel, de l'RFC de TCP): sigues conservador en el que envies i liberal en el que acceptes. En APIs es tradueix en dues disciplines que, juntes, fan compatibles la majoria de canvis sense versionar res:

El consumidor és tolerant (tolerant reader):

  • Ignora els camps que no coneix. Mai no valida "el JSON ha de tenir exactament aquests camps". Així, quan Catàleg afegeix atributs, el traductorProducte de Comandes ni se n'assabenta.
  • Llegeix només el que necessita. Si Comandes fa servir id, nom, preu i disponible, el seu codi no toca res més i no depèn de res més.
  • No depèn de l'ordre dels camps, ni de les claus d'un objecte, ni dels elements d'una llista llevat que el contracte ho garanteixi.
  • Tracta els enum amb un cas per defecte: un estat desconegut no trenca l'aplicació; es mostra tal qual o es registra un avís.
  • No falla per camps opcionals absents: producte.atributs ?? {}.

El productor és conservador:

  • Envia sempre el que ha promès, en el tipus promès, encara que el valor sigui buit ([], no absència; null només si el contracte ho permet).
  • No reutilitza noms amb un altre significat.
  • Afegeix, no canvia. Si necessita preu amb moneda, afegeix preuDetallat i manté preu fins que retiri la versió (apartat 10).
  • Documenta el que afegeix (OpenAPI/AsyncAPI) el mateix dia que ho desplega.

Exemple de lector tolerant al traductorProducte de Comandes (l'ACL de 02-03):

// traductors/traductorProducte.js (servei-comandes)
// Converteix el JSON públic de Catàleg al model intern de Comandes.
// Només toca els camps que Comandes necessita; tota la resta s'ignora (tolerant reader).
function aProducteDeComandes(dto) {
  return {
    producteId: dto.id,
    nom: dto.nom,
    preuUnitari: Number(dto.preu),            // Number() per si un dia arribés com a cadena
    disponible: dto.disponible !== false      // absent → assumim disponible; false explícit → no
  };
}

Amb aquest traductor, Catàleg pot afegir deu camps sense que Comandes canviï ni una línia. El que no sobreviu és que preu passi a ser un objecte: això ja no és tolerància, és una versió nova.

  1. Estratègies de versionat REST i l'elecció de TechCorp

Quan el canvi és incompatible i necessari, cal servir dues versions alhora durant un temps. Maneres d'indicar la versió:

Estratègia Exemple Avantatges Inconvenients
A la URI GET /v1/productes, GET /v2/productes Visible, trivial d'encaminar (el gateway envia /v2/* on vulgui), fàcil de provar amb curl, es pot desar en memòria cau per URL "Contamina" la URI (puristes: la versió no és part del recurs); duplica documentació; tempta a versionar tota l'API per un sol endpoint
A la capçalera Accept (negociació de contingut) Accept: application/vnd.techcorp.productes.v2+json URIs netes i estables; versió per representació, no per API; permet versionar un sol recurs Invisible a la URL (difícil de depurar i de desar en memòria cau); els clients obliden la capçalera i reben la versió per defecte sense saber-ho; encaminament per capçalera al gateway
Capçalera pròpia X-Api-Version: 2 Senzilla No estàndard; mateixos problemes d'invisibilitat
Paràmetre de query GET /productes?version=2 Fàcil de provar Barreja versió amb filtres; es perd als enllaços; poc habitual
Sense versió (només evolució compatible) Zero complexitat No hi ha sortida quan un canvi incompatible és inevitable

Elecció de TechCorp, alineada amb la pràctica majoritària:

  • Versió major a la URI: /v1/productes, /v2/productes. Motiu: és la més visible, la més fàcil d'encaminar al gateway de 03-04 (/api/v2/productes → pot anar fins i tot a un desplegament diferent) i la que menys errors silenciosos produeix als consumidors (no hi ha "versió per defecte" que es coli per oblidar una capçalera).
  • Només versions majors. No hi ha /v1.2/: dins de /v1/ l'API només evoluciona de manera compatible (apartat 2). Una versió nova és un esdeveniment rar i planificat, no un increment rutinari.
  • La versió és per servei, no global: Catàleg pot ser a /v2/ i Comandes a /v1/. Cada equip versiona el seu contracte.
  • Tots els contractes de 03-01 passen a portar /v1/: POST /v1/comandes, GET /v1/productes?ids=, GET /v1/clients/{id}, POST /v1/reserves. Al gateway: /api/v1/comandes/*servei-comandes:3002/v1/comandes/*. Fins ara ho hem omès per claredat; des d'aquesta lliçó és part del contracte.

  1. Cicle de vida d'una versió

Una versió nova no substitueix l'anterior de cop: hi conviu i la retira de manera ordenada.

stateDiagram-v2
    [*] --> Activa: publicar /v2/
    Activa --> Deprecada: anunciar retirada de /v1/ (capçaleres Deprecation/Sunset)
    Deprecada --> Retirada: data Sunset assolida i trànsit ~0
    Retirada --> [*]: /v1/ respon 410 Gone

Etapes i pràctiques:

  1. Publicació de v2. v1 continua activa i sense canvis. S'anuncia als consumidors coneguts (canal intern, changelog de l'API) amb la guia de migració.
  2. Deprecació de v1. v1 continua funcionant però avisa a cada resposta amb dues capçaleres estàndard:
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: true
Sunset: Sat, 28 Feb 2027 00:00:00 GMT
Link: <https://docs.techcorp.example/api/cataleg/v2/migracio>; rel="successor-version"

Deprecation (RFC 9745) diu "això es retirarà"; Sunset (RFC 8594) diu quan deixarà de respondre; Link rel="successor-version" apunta on cal migrar. Els clients ben fets registren un avís en veure Deprecation. 3. Finestra de convivència. Per a consumidors interns de TechCorp, un mínim de dos cicles de desplegament de tots els equips afectats (a la pràctica, 4-8 setmanes). Per a consumidors externs (l'ERP de socis), un mínim de 6 mesos per contracte comercial. Durant la finestra, totes dues versions es proven i es despleguen. 4. Mètriques d'ús per versió. El gateway (03-04) etiqueta cada petició amb la seva versió i exposa un comptador (peticions_total{servei="cataleg",version="v1"}, en el format de 06-01). No es retira una versió fins que el seu trànsit és zero o els últims consumidors estan identificats i avisats. Sense mètriques, retirar és endevinar. 5. Retirada. /v1/* respon 410 Gone (no 404: el recurs va existir i s'ha retirat a propòsit) amb un problema RFC 7807 que enllaça a v2. Al cap d'unes setmanes s'elimina el codi.

I una regla de cost: cada versió activa és codi que cal mantenir, provar i desplegar. Dues versions alhora és normal; tres és un senyal que les retirades no s'estan fent.

  1. Versionat d'esdeveniments

Els esdeveniments són contractes encara més delicats que les APIs, per dos motius: el productor no sap qui consumeix (03-02), de manera que no pot avisar ningú en concret, i els esdeveniments es poden quedar en una cua (o en una DLQ) durant hores o dies i ser processats per un consumidor més nou o més vell que el productor que els va emetre.

Les eines:

  • El camp versio del sobre (02-05): { esdevenimentId, tipus, versio: 1, ocorregutEn, carrega }. És la versió de l'esquema de la càrrega d'aquell tipus d'esdeveniment. S'incrementa només en canvis incompatibles de la càrrega.
  • Evolució compatible de la càrrega (mateixa versio): afegir camps opcionals, afegir valors a llistes. Els consumidors tolerants (apartat 3) no noten res. Exemple: afegir canalVenda: "web" | "mobil" a comanda.creada és versio: 1 amb un camp més.
  • Canvi incompatible → versio: 2, i el productor publica només la nova (publicar-ne les dues duplicaria el processament). Els consumidors han de poder llegir totes dues mentre hi hagi esdeveniments v1 en circulació.
  • Upcasting al consumidor: en rebre un esdeveniment, el consumidor el passa per una cadena de funcions que converteixen cada versió antiga a la següent, fins a l'actual, i la resta del codi només coneix l'última. És la manera més neta de suportar N versions sense if (versio === 1) repartits per tot el gestor.
// missatgeria/upcasters/comandaCreada.js (a cada consumidor de comanda.creada)
// Cada funció converteix la càrrega de la versió N a la N+1. S'apliquen en cadena.
const upcasters = {
  // v1 → v2: a v2 'total' va passar a ser un objecte {import, moneda} i les línies porten 'moneda'
  1: (carrega) => ({
    ...carrega,
    total: { import: carrega.total, moneda: 'EUR' },
    linies: carrega.linies.map(l => ({ ...l, preuUnitari: { import: l.preuUnitari, moneda: 'EUR' } }))
  })
  // 2: (carrega) => ... quan existeixi v3
};

const VERSIO_ACTUAL = 2;

function normalitzarComandaCreada(sobre) {
  let { versio, carrega } = sobre;
  while (versio < VERSIO_ACTUAL) {
    const pujar = upcasters[versio];
    if (!pujar) throw new Error(`No sé convertir comanda.creada v${versio}`);
    carrega = pujar(carrega);
    versio++;
  }
  if (versio > VERSIO_ACTUAL) {
    // Un productor més nou que jo: si la càrrega és un superconjunt compatible, continuar; si no, DLQ (03-02)
    console.warn('comanda.creada amb versió superior a la coneguda', { versio, esdevenimentId: sobre.esdevenimentId });
  }
  return carrega; // sempre en la forma de VERSIO_ACTUAL
}

El gestor d'Inventari crida normalitzarComandaCreada(sobre) i treballa sempre amb la forma v2, rebi el que rebi.

  • Quan un esdeveniment nou en lloc d'una versió nova. Si el significat canvia, no és una versió: és un altre esdeveniment. comanda.creada v2 continua sent "s'ha creat una comanda" amb una altra forma; si el que es vol comunicar és "la comanda ha sortit del magatzem", això és comanda.enviada, encara que la càrrega s'hi assembli. Regla: mateixa semàntica i forma diferent → nova versió; semàntica diferent → nou tipus. També convé un esdeveniment nou quan la càrrega canvia tant que l'upcasting seria inventar dades que el productor vell no va tenir mai.

I una regla operativa que es deriva de la topologia de 03-02: com que la routing key és el tipus (comanda.creada), la versió no va a la routing key. Posar comanda.creada.v2 com a routing key trencaria els bindings de tots els consumidors i els obligaria a subscriure's a cada versió: just el contrari del que volem.

  1. Versionat de Protobuf i de GraphQL

Protocol Buffers (03-03) està dissenyat per a l'evolució compatible, amb regles molt concretes:

  • El que identifica un camp al binari és el seu número, no el seu nom. Canviar el nom de producte_id a id_producte és compatible (només canvia el codi generat); canviar el número no ho és i reutilitzar un número esborrat és catastròfic: els missatges vells s'interpretarien amb el tipus/significat nou.
  • Afegir un camp amb un número nou és compatible: els receptors vells l'ignoren, els nous veuen el valor per defecte als missatges vells.
  • Eliminar un camp: s'esborra del .proto i el seu número (i el seu nom) es marquen com a reserved perquè ningú no els reutilitzi.
  • Canviar el tipus només és segur entre tipus compatibles al cable (int32/int64/bool, string/bytes amb UTF-8); a la pràctica, tracta-ho com a incompatible.
  • Canvis incompatibles de servei (signatura d'un rpc) → un package nou (techcorp.inventari.v2) que conviu amb l'anterior.
message SollicitudReserva {
  string comanda_id = 1;
  repeated LiniaReserva linies = 2;
  int32 expira_en_segons = 3;
  // Es va eliminar 'string magatzem = 4' el 2026-09: no reutilitzar mai ni el número ni el nom
  reserved 4;
  reserved "magatzem";
  string canal_venda = 5;            // afegit el 2026-09, compatible
}

GraphQL (03-03) pren el camí oposat al versionat: no hi ha versions de l'esquema. La filosofia és l'evolució contínua del graf:

  • Afegir tipus, camps, arguments opcionals i valors d'enum és compatible (els clients demanen només el que coneixen: l'over-fetching nul fa que un camp nou no arribi a ningú que no el demani).
  • Un camp que cal retirar es marca amb @deprecated(reason: "..."); les eines dels clients ho mostren, i el servidor pot mesurar amb exactitud qui el demana encara (cada consulta declara els seus camps), cosa que fa la retirada molt més segura que a REST.
  • Els canvis de tipus es fan afegint un camp nou (preuDetallat: Preu!) i deprecant el vell.
type Producte {
  id: ID!
  nom: String!
  preu: Float! @deprecated(reason: "Fes servir preuDetallat; es retira el 2027-02-28")
  preuDetallat: Preu!
  moneda: String! @deprecated(reason: "Inclosa a preuDetallat")
}
type Preu { import: Float!, moneda: String! }

  1. Contracte primer: OpenAPI i AsyncAPI

Escriure el contracte abans que el codi (design-first, contract-first) canvia la dinàmica entre equips: Catàleg i Comandes acorden el YAML de GET /v1/productes en una hora, i des d'aquell moment l'un implementa el servidor, l'altre el client (amb un mock generat del contracte) i es troben a la integració amb la forma ja pactada. El contracte és també on es revisa un canvi: un pull request al YAML és el lloc natural perquè un consumidor digui "això em trenca".

Per a REST ja vam veure OpenAPI 3 (03-01). Per a esdeveniments existeix el seu equivalent: AsyncAPI, que descriu canals (a RabbitMQ, exchanges i routing keys), missatges i els seus esquemes. Fragment per a comanda.creada:

asyncapi: 3.0.0
info:
  title: TechCorp - Esdeveniments de Comandes
  version: 1.2.0            # versió del DOCUMENT; la de l'esquema de cada esdeveniment va al sobre
servers:
  rabbitmq:
    host: rabbitmq:5672
    protocol: amqp
channels:
  comandaCreada:
    address: comanda.creada         # routing key a l'exchange techcorp.esdeveniments (03-02)
    messages:
      comandaCreada:
        $ref: '#/components/messages/ComandaCreada'
    bindings:
      amqp:
        is: routingKey
        exchange: { name: techcorp.esdeveniments, type: topic, durable: true }
operations:
  publicarComandaCreada:
    action: send
    channel: { $ref: '#/channels/comandaCreada' }
    summary: Comandes publica aquest esdeveniment en acceptar una comanda (POST /v1/comandes → 202)
components:
  messages:
    ComandaCreada:
      name: comanda.creada
      contentType: application/json
      payload:
        $ref: '#/components/schemas/SobreComandaCreada'
  schemas:
    SobreComandaCreada:
      type: object
      required: [esdevenimentId, tipus, versio, ocorregutEn, carrega]
      properties:
        esdevenimentId: { type: string, example: evt-3f9c... }
        tipus: { type: string, const: comanda.creada }
        versio: { type: integer, example: 1 }
        ocorregutEn: { type: string, format: date-time }
        carrega:
          type: object
          required: [comandaId, clientId, client, adrecaEnviament, linies, total]
          properties:
            comandaId: { type: string, example: com-88213 }
            clientId: { type: string, example: c-1024 }
            client:
              type: object
              required: [email, nom]
              properties:
                email: { type: string, format: email }
                nom: { type: string }
            adrecaEnviament: { $ref: '#/components/schemas/Adreca' }
            linies:
              type: array
              minItems: 1
              items:
                type: object
                required: [producteId, nom, quantitat, preuUnitari]
                properties:
                  producteId: { type: string }
                  nom: { type: string }
                  quantitat: { type: integer, minimum: 1 }
                  preuUnitari: { type: number }
            total: { type: number, example: 79.70 }
            canalVenda: { type: string, enum: [web, mobil], description: "Afegit a v1 (compatible, opcional)" }

Com es fa servir: el document viu al repositori de Comandes (el productor), els consumidors el llegeixen per generar validadors o stubs, i qualsevol canvi passa per revisió. Igual que amb OpenAPI, cada servei publica el seu AsyncAPI per als esdeveniments que produeix.

  1. Tests de contracte dirigits pel consumidor (introducció)

Documentar el contracte no garanteix complir-lo. Els tests de contracte dirigits pel consumidor (consumer-driven contract tests, amb Pact com a eina de referència) tanquen aquest forat:

  1. El consumidor (Comandes) escriu, a les seves pròpies proves, les interaccions que espera del productor: "quan demani GET /v1/productes?ids=p-501 espero 200 amb un objecte que tingui dades[0].id, nom (cadena), preu (número) i disponible (booleà)". Només els camps que fa servir, no tota la resposta (tolerància, un altre cop).
  2. D'aquestes proves es genera un fitxer de pacte (JSON) que es publica en un broker de pactes.
  3. Al CI del productor (Catàleg), es descarreguen els pactes de tots els seus consumidors i es verifiquen contra el servei real: si Catàleg canvia preu a objecte, el pacte de Comandes falla al CI de Catàleg, abans de desplegar.

El que és valuós: el productor sap exactament quins camps fa servir cada consumidor (pot retirar sense por el que ningú no pacta) i un canvi incompatible es detecta on s'origina. S'implementen a 04-05; aquí n'hi ha prou de saber que existeixen i que són la xarxa de seguretat de tot l'anterior.

  1. Exemple guiat: fer evolucionar GET /productes

Situació inicial (03-01, ara amb /v1/):

{ "dades": [ { "id": "p-501", "nom": "Auriculars BT X200", "preu": 59.90, "moneda": "EUR", "disponible": true } ], "noTrobats": [] }

Consumidors: Comandes (traductorProducte: fa servir id, nom, preu, disponible), el BFF mòbil (fa servir a més imatgeUrl) i l'ERP de dos socis.

Pas 1: afegir atributs (compatible)

El catàleg a MongoDB ja desa atributs (02-04) i la web els vol mostrar. Canvi: afegir un camp opcional a la resposta.

{ "id": "p-501", "nom": "Auriculars BT X200", "preu": 59.90, "moneda": "EUR", "disponible": true,
  "atributs": { "color": "negre", "connexio": "Bluetooth 5.3", "autonomiaHores": 30 } }

Procediment: (1) pull request a l'OpenAPI de Catàleg afegint atributs com a object opcional amb additionalProperties; (2) els consumidors no fan res (lectors tolerants: Comandes l'ignora, el BFF el fa servir quan vulgui); (3) es desplega a /v1/; (4) els pactes de Comandes i del BFF continuen passant perquè només comproven els camps que fan servir. Cost per als altres equips: zero. Això és el que hauria de ser el 95 % dels canvis.

Pas 2: preu passa de número a objecte {import, moneda} (incompatible)

TechCorp vendrà a Portugal i al Regne Unit; un preu sense la moneda enganxada és font d'errors, i moneda com a camp germà s'oblida. Es decideix que preu sigui { "import": 59.90, "moneda": "EUR" }. Canviar el tipus d'un camp és incompatible: Number(dto.preu) a Comandes donaria NaN i l'ERP d'un soci sumaria objectes.

Hi ha una opció compatible que es considera primer: afegir preuDetallat: {import, moneda} i mantenir preu numèric per sempre. És el que faria GraphQL. Catàleg la descarta per dos motius legítims: quedarien dos camps amb la mateixa dada (font d'inconsistències) i preu numèric sense moneda és justament el model que es vol prohibir. Així que v2.

Pla:

  1. Disseny de v2 (setmana 0): OpenAPI de /v2/productes amb preu com a objecte i sense moneda solta; s'aprofita per deixar imatgeUrl obligatori (un altre canvi incompatible que s'agrupa a la mateixa versió: les versions majors són cares, millor poques i amb diversos canvis). Revisió amb Comandes, BFF i els socis.
  2. Implementació (setmanes 1-2): Catàleg serveix /v1/ i /v2/ des del mateix codi; internament el model és el nou i una capa de traducció cap enrere genera la forma v1 (preu: import, moneda). Així v1 no és una branca de codi congelada, sinó una vista.
  3. Publicació de v2 i deprecació de v1 (setmana 2): /v1/productes comença a respondre amb Deprecation: true, Sunset a 6 mesos (pels socis externs) i Link a la guia de migració. El gateway afegeix la ruta /api/v2/productes/* i etiqueta les mètriques per versió.
  4. Migració de Comandes (setmanes 3-4), el consumidor intern crític:
// traductors/traductorProducte.js — versió que consumeix /v2/productes
function aProducteDeComandes(dto) {
  return {
    producteId: dto.id,
    nom: dto.nom,
    preuUnitari: Number(dto.preu.import),
    moneda: dto.preu.moneda,               // Comandes comença a desar la moneda a linies_comanda (columna nova, opcional)
    disponible: dto.disponible !== false
  };
}

La migració és: canviar CATALEG_URL de base /v1 a /v2 (o la ruta al client), actualitzar el traductor, actualitzar el pacte de Comandes contra /v2/, i una migració d'esquema a Comandes (columna moneda, per defecte EUR, compatible). Es desplega amb l'estratègia de 05-04 i es vigila la ràtio d'errors de POST /comandes. 5. Migració del BFF mòbil (setmana 4) i avís formal als socis (mes 1) amb la data Sunset. 6. Seguiment (mesos 2-6): el panell de 06-01 mostra peticions_total{version="v1"} baixant. Al mes 5, un soci continua a v1: se'l contacta directament (les mètriques per token de client diuen qui és). 7. Retirada (mes 6): /v1/productes respon 410 Gone amb un problema RFC 7807 (codi: VERSIO_RETIRADA, detail amb la URL de v2). Un mes després s'esborra la capa de traducció cap enrere.

El que ha fet possible que un canvi de tipus al camp més usat de l'API no causi ni un incident: contracte escrit i revisat abans de codificar, consumidors tolerants que només depenen del que fan servir, versió major a la URI amb dues versions convivint, capçaleres de deprecació, mètriques per versió i pactes que haurien fallat al CI si algú s'hagués saltat l'ordre.

Errors Comuns i Consells

  • Validar estrictament la resposta aliena ("rebutja si hi ha camps desconeguts"). Converteix cada camp nou del productor en un trencament del consumidor. Lector tolerant sempre.
  • Canviar semàntica sense canviar forma. El canvi més perillós perquè cap prova automàtica no el veu. Si preu passa a incloure IVA, és una versió nova (o un camp nou), encara que continuï sent un número.
  • Versionar per costum (/v1.3/, /v1.4/). Cada versió és cost; dins d'una versió major només evolució compatible.
  • Posar la versió a la routing key dels esdeveniments. Trenca tots els bindings. La versió va al sobre.
  • Publicar un esdeveniment en dues versions alhora. Duplica el processament a tots els consumidors. Es publica la nova; els consumidors fan upcasting.
  • Reutilitzar números de camp a Protobuf. reserved sempre en esborrar.
  • Retirar una versió sense mètriques. "Ningú no fa servir v1" és una hipòtesi fins que un comptador ho confirma.
  • Capçalera Deprecation sense Sunset. Un avís sense data no mou ningú.
  • Contracte escrit després del codi. Es desactualitza a la primera iteració i deixa de servir per a la revisió. Primer el YAML, després el codi, i el CI que comprovi que coincideixen.
  • Documentar només el que retornes avui sense dir què és contracte i què no. Deixa clar a OpenAPI/AsyncAPI que els camps no documentats no existeixen i que l'ordre no importa.

Exercicis

Exercici 1. Classifica cada canvi proposat a l'API de Comandes com a compatible o incompatible, indica què faria l'equip (desplegar a /v1/, afegir camp, nova versió) i quin consumidor de TechCorp es podria trencar: (a) POST /v1/comandes accepta un nou camp opcional cupo; (b) GET /v1/comandes/{id} deixa de retornar historial perquè és car de calcular; (c) l'estat PAGADA es reanomena COBRADA; (d) s'afegeix l'estat EN_REPARTIMENT a la màquina d'estats; (e) total passa a incloure les despeses d'enviament; (f) Idempotency-Key passa d'obligatòria a opcional.

Exercici 2. L'equip de Comandes vol afegir a comanda.creada un bloc facturacio: { nif, raoSocial } per a clients empresa (opcional) i, a més, canviar adrecaEnviament.pais de codi ISO de dues lletres ("ES") a nom complet ("Espanya"). Decideix per a cada canvi si és una nova versio del sobre, i en cas afirmatiu escriu l'upcaster que necessitaria Notificacions (que imprimeix el país al correu) per passar de la versió antiga a la nova. Després argumenta si el segon canvi s'hauria de fer ni que sigui.

Exercici 3. Un soci extern consumeix GET /v1/productes des del seu ERP i, sis setmanes abans de la data Sunset, avisa que no arribarà a migrar. Proposa tres opcions (amb pros i contres) que TechCorp podria oferir sense trencar la regla d'"una versió activa per servei a llarg termini", i digues quina recomanaries.

Solucions

Solució 1.

Canvi Compatible? Acció Qui es trenca si es fa malament
a Camp opcional cupo a la petició Desplegar a /v1/; documentar a OpenAPI Ningú
b Eliminar historial de la resposta No Alternatives compatibles: mantenir-lo i calcular-lo sota demanda amb ?incloure=historial (nou paràmetre opcional, per defecte amb historial per no trencar), o moure'l a GET /v1/comandes/{id}/historial mantenint el camp fins a una v2 La web (mostra la línia de temps de la comanda)
c Reanomenar PAGADACOBRADA No (canvia un valor d'enum que els clients comparen) No fer-ho; si és imprescindible, v2 Web, BFF mòbil, qualsevol switch d'estats
d Afegir EN_REPARTIMENT Formalment incompatible (nou valor d'enum) Es pot fer a /v1/ si el contracte ja deia "poden aparèixer estats nous; tracta'ls amb un cas per defecte" i els consumidors ho compleixen; avisar i comprovar el BFF i la web abans Front-ends amb mapatge exhaustiu d'estats
e total inclou enviament No (canvi de semàntica) Afegir totalAmbEnviament i despesesEnviament com a camps nous i deixar total com estava; o v2 Comandes↔Pagaments (l'import a cobrar), l'ERP de socis, comptabilitat: i sense cap error visible
f Idempotency-Key passa a opcional Sí (relaxar una validació) Desplegar a /v1/ (tot i que és mala idea de disseny: 02-05 la vol obligatòria) Ningú no es trenca; es perd una garantia

Solució 2.

  • facturacio opcional: compatible, mateixa versio: 1. Els consumidors tolerants l'ignoren; Notificacions el podrà fer servir per al correu quan vulgui. Es documenta a l'AsyncAPI.
  • pais de "ES" a "Espanya": incompatible (canvia el format/semàntica d'un camp existent): versio: 2. Upcaster de Notificacions:
const NOMS_PAIS = { ES: 'Espanya', PT: 'Portugal', GB: 'Regne Unit', FR: 'França' };
const upcasters = {
  1: (carrega) => ({
    ...carrega,
    adrecaEnviament: { ...carrega.adrecaEnviament, pais: NOMS_PAIS[carrega.adrecaEnviament.pais] ?? carrega.adrecaEnviament.pais }
  })
};

S'hauria de fer? No. El codi ISO és el format interoperable (el fa servir Clients, el fan servir les passarel·les, el fan servir els transportistes); un nom en català no serveix per a res més que per imprimir, i això és responsabilitat de presentació de Notificacions (que pot tenir la seva pròpia taula de noms, o rebre paisNom com a camp addicional compatible). Es trencaria la compatibilitat, s'obligaria tots els consumidors a fer upcasting, i es perdria informació (el codi) per comoditat d'un de sol. La resposta correcta a l'equip de Comandes: afegeix adrecaEnviament.paisNom opcional si de debò fa falta, i continua a versio: 1.

Solució 3.

  1. Ampliar el Sunset de v1 per a tothom (p. ex. 3 mesos més). Pros: senzill, sense excepcions. Contres: manté el cost de dues versions per a tothom per un sol consumidor; crea un precedent.
  2. Excepció per client: v1 continua responent només per al token d'aquell soci (el gateway encamina per identitat; per a la resta, 410). Pros: la finestra es tanca per a tots els altres; el cost s'acota; pressiona el soci amb una data nova i ferma. Contres: lògica d'excepció al gateway/servei; cal retirar-la després.
  3. Adaptador temporal per al soci: un petit BFF (03-04) o middleware que tradueix v2 → forma v1 (preu.importpreu) per al seu ERP, desplegat per TechCorp o lliurat al soci com a llibreria. Pros: Catàleg retira v1 en la data prevista; la traducció és trivial. Contres: és un component més; només val si la traducció és mecànica (aquí ho és).

Recomanació: la 2 amb data límit curta i no negociable, o la 3 si la relació comercial ho justifica i la traducció és tan simple com en aquest cas. La 1 penalitza tothom per un de sol. En qualsevol cas, la mètrica per versió i per client és el que permet prendre la decisió amb dades i no amb suposicions.

Conclusió

El contracte és l'única frontera entre equips i, ben gestionada, la que permet que cadascun desplegui al seu ritme. Hem separat els canvis compatibles (afegir camps opcionals, endpoints, esdeveniments) dels incompatibles (canviar el nom, canviar el tipus, eliminar, endurir validacions i, el més traïdor, canviar la semàntica), i hem vist que la regla de tolerància (consumidor que ignora el que desconeix, productor que només afegeix) absorbeix la gran majoria de l'evolució sense versionar res. Per a la resta: versió major a la URI (/v1/, /v2/) amb evolució compatible dins de cadascuna, cicle de vida amb Deprecation/Sunset, finestra de convivència i mètriques d'ús abans de retirar; camp versio del sobre i upcasting als consumidors per als esdeveniments (i un esdeveniment nou quan canvia el significat); números de camp i reserved a Protobuf; @deprecated per camp i sense versions a GraphQL; contracte primer amb OpenAPI i AsyncAPI; i els tests de contracte dirigits pel consumidor com a xarxa de seguretat. L'exemple de GET /productes ha recorregut el camí complet, des d'afegir atributs sense que ningú se n'assabenti fins a canviar preu a {import, moneda} amb sis mesos de convivència i la migració ordenada de Comandes.

Amb això acaba el mòdul de comunicació: sabem dissenyar APIs REST amb els seus contractes, codis i errors uniformes; publicar i consumir esdeveniments a RabbitMQ amb garanties at-least-once; quan recórrer a gRPC o GraphQL; què fa i què no fa l'API Gateway del port 8080 i per què existeixen els BFF; com es troben i es balancegen els serveis amb el DNS de Kubernetes i els health checks; i com evolucionen tots aquests contractes sense trencar ningú. El que encara no existeix és el codi d'un servei complet: fins ara hem escrit rutes, publicadors i consumidors solts. Al mòdul 4 triarem les eines concretes de l'stack, muntarem un microservei des de zero (estructura de projecte, configuració, arrencada, salut), el connectarem de debò als altres serveis i a RabbitMQ seguint aquests contractes, i li posarem proves unitàries, d'integració i de contracte amb Pact. Comença per l'elecció de tecnologies i eines.

Curs de Microserveis

Mòdul 1: Introducció als Microserveis

Mòdul 2: Disseny de Microserveis

Mòdul 3: Comunicació entre Microserveis

Mòdul 4: Implementació de Microserveis

Mòdul 5: Desplegament i Orquestració

Mòdul 6: Monitoratge i Manteniment

Mòdul 7: Seguretat en Microserveis

Mòdul 8: Casos d'Estudi i Exemples Pràctics

© Copyright 2026. Tots els drets reservats