Todo lo que hemos construido en este módulo (los contratos REST de 03-01, el sobre de evento y las cargas de 03-02, el .proto de Inventario y el esquema GraphQL de 03-03) tiene algo en común: son contratos entre equipos que van a evolucionar. El equipo de Experiencia de compra querrá añadir atributos a los productos; el de Pedidos querrá un campo nuevo en pedido.creado; alguien decidirá que precio como número suelto fue un error y que debería llevar moneda. Cada uno de esos cambios puede romper silenciosamente a un consumidor que nadie avisó. En 02-01 dijimos que el único acoplamiento aceptable entre servicios es el de contrato; esta lección explica cómo gestionar ese acoplamiento para que un cambio en un servicio no se convierta en un incidente en otro.
Veremos el contrato como frontera entre equipos, qué cambios son compatibles y cuáles no, la regla de tolerancia (Postel) que evita la mayoría de roturas, las estrategias de versionado REST con la elección de TechCorp, el ciclo de vida de una versión (deprecación, convivencia, retirada), el versionado de eventos (el campo version del sobre, evolución compatible, upcasting), el de Protobuf y GraphQL, el enfoque design-first con OpenAPI y AsyncAPI, una introducción conceptual a los tests de contrato dirigidos por el consumidor, y un ejemplo guiado completo: evolucionar GET /productos primero de forma compatible y después incompatible, con su periodo de convivencia y el plan de migración de Pedidos. La implementación de los tests de contrato con Pact es de 04-05.
Contenido
- El contrato como frontera entre equipos
- Cambios compatibles e incompatibles
- La regla de tolerancia: consumidor tolerante, productor conservador
- Estrategias de versionado REST y la elección de TechCorp
- Ciclo de vida de una versión
- Versionado de eventos
- Versionado de Protobuf y de GraphQL
- Contrato primero: OpenAPI y AsyncAPI
- Tests de contrato dirigidos por el consumidor (introducción)
- Ejemplo guiado: evolucionar
GET /productos
- El contrato como frontera entre equipos
Un contrato es todo aquello de lo que un consumidor puede depender legítimamente: la forma de la petición y la respuesta, los códigos de estado, los campos y sus tipos, la semántica de cada uno, los códigos de error (codigo), las cabeceras, el orden (o no) de los eventos, los valores de un enum. Lo que no es contrato: la implementación, la base de datos, el orden de las claves en el JSON, el texto de detail, los campos no documentados.
En microservicios el contrato es la única superficie de contacto entre equipos, y por eso concentra dos tensiones opuestas:
- El productor (Catálogo) quiere cambiar su API cuando su negocio cambia, sin pedir permiso.
- Los consumidores (Pedidos, el BFF móvil, el ERP de un socio) quieren que nada cambie sin avisar, porque cada cambio es trabajo y riesgo para ellos.
Del mapa de contextos de 02-03 sale quién manda en cada relación: en un open host service con published language (Catálogo → Pedidos), el productor publica y los consumidores se adaptan, pero con reglas de evolución que esta lección fija; en customer-supplier (Clientes → Pedidos), el consumidor tiene voz en el contrato; en partnership (Pedidos ↔ Inventario), lo negocian juntos. En todos los casos, la disciplina es la misma: los cambios compatibles se hacen libremente; los incompatibles requieren una versión nueva y un periodo de convivencia.
- Cambios compatibles e incompatibles
Un cambio es compatible hacia atrás si un consumidor escrito contra la versión anterior sigue funcionando sin tocarlo. Es la única propiedad que importa.
| Cambio | ¿Compatible? | Por qué | Ejemplo en TechCorp |
|---|---|---|---|
| Añadir un campo opcional en la respuesta | Sí (si el consumidor ignora lo desconocido) | El consumidor viejo no lo lee | Añadir atributos a GET /productos |
| Añadir un campo opcional en la petición | Sí | El consumidor viejo no lo envía y el servidor asume el valor por defecto | POST /pedidos acepta notas opcional |
| Añadir un endpoint o un método nuevo | Sí | Nadie lo usaba | POST /pedidos/{id}/cancelacion |
Añadir un valor a un enum en la respuesta |
Depende (formalmente incompatible) | Un consumidor con switch exhaustivo sobre estados rompe con un valor nuevo |
Añadir EN_REPARTO a los estados del pedido: el front que mapea estados a textos muestra undefined |
| Añadir un evento nuevo | Sí | Nadie está suscrito | pedido.enviado |
| Renombrar un campo | No | El consumidor lee el nombre viejo y obtiene undefined |
precio → precioUnitario |
| Cambiar el tipo de un campo | No | precio: 59.90 → precio: {importe, moneda} rompe cualquier total += precio |
El ejemplo del apartado 10 |
| Eliminar un campo o un endpoint | No | El consumidor lo necesita | Quitar disponible de GET /productos |
| Hacer obligatorio un campo opcional de la petición | No | Peticiones viejas válidas empiezan a dar 400 |
Exigir pais en direccionEnvio |
| Hacer opcional (anulable) un campo que era obligatorio en la respuesta | No | El consumidor asume que siempre viene | nombre pasa a poder ser null |
| Cambiar la semántica sin cambiar la forma | No, y es el peor | Nada falla en tiempo de compilación ni en las pruebas; falla el negocio | precio pasa de "sin IVA" a "con IVA"; total deja de incluir envío |
Cambiar un código de estado o un codigo de error |
No | Los clientes hacen switch sobre ellos |
SIN_STOCK de 409 a 422 |
| Cambiar la URL de un recurso | No | Los enlaces guardados y el código de los clientes apuntan a la vieja | /productos → /articulos |
| Endurecer una validación | No | Peticiones que antes pasaban ahora fallan | Bajar el máximo de líneas de 100 a 50 |
| Relajar una validación | Sí | Nada que pasaba deja de pasar | Subir el máximo de 50 a 100 |
Regla mnemotécnica: añadir es (casi siempre) compatible; quitar, renombrar, cambiar de tipo o de significado no lo es. Y el cambio de semántica merece atención especial porque no lo detecta ninguna herramienta: solo la comunicación entre equipos y los tests de contrato.
- La regla de tolerancia: consumidor tolerante, productor conservador
El principio de robustez (o ley de Postel, del RFC de TCP): sé conservador en lo que envías y liberal en lo que aceptas. En APIs se traduce en dos disciplinas que, juntas, hacen compatibles la mayoría de cambios sin versionar nada:
El consumidor es tolerante (tolerant reader):
- Ignora los campos que no conoce. Nunca valida "el JSON debe tener exactamente estos campos". Así, cuando Catálogo añade
atributos, eltraductorProductode Pedidos ni se entera. - Lee solo lo que necesita. Si Pedidos usa
id,nombre,precioydisponible, su código no toca nada más y no depende de nada más. - No depende del orden de los campos, ni de las claves de un objeto, ni de elementos de una lista salvo que el contrato lo garantice.
- Trata los
enumcon un caso por defecto: un estado desconocido no rompe la aplicación; se muestra tal cual o se registra un aviso. - No falla por campos opcionales ausentes:
producto.atributos ?? {}.
El productor es conservador:
- Envía siempre lo que prometió, en el tipo prometido, aunque el valor sea vacío (
[], no ausencia;nullsolo si el contrato lo permite). - No reutiliza nombres con otro significado.
- Añade, no cambia. Si necesita
preciocon moneda, añadeprecioDetalladoy mantienepreciohasta que retire la versión (apartado 10). - Documenta lo que añade (OpenAPI/AsyncAPI) el mismo día que lo despliega.
Ejemplo de lector tolerante en el traductorProducto de Pedidos (el ACL de 02-03):
// traductores/traductorProducto.js (servicio-pedidos)
// Convierte el JSON público de Catálogo al modelo interno de Pedidos.
// Solo toca los campos que Pedidos necesita; todo lo demás se ignora (tolerant reader).
function aProductoDePedidos(dto) {
return {
productoId: dto.id,
nombre: dto.nombre,
precioUnitario: Number(dto.precio), // Number() por si un día llegara como cadena
disponible: dto.disponible !== false // ausente → asumimos disponible; false explícito → no
};
}Con este traductor, Catálogo puede añadir diez campos sin que Pedidos cambie una línea. Lo que no sobrevive es que precio pase a ser un objeto: eso ya no es tolerancia, es una versión nueva.
- Estrategias de versionado REST y la elección de TechCorp
Cuando el cambio es incompatible y necesario, hay que servir dos versiones a la vez durante un tiempo. Formas de indicar la versión:
| Estrategia | Ejemplo | Ventajas | Inconvenientes |
|---|---|---|---|
| En la URI | GET /v1/productos, GET /v2/productos |
Visible, trivial de enrutar (el gateway manda /v2/* donde quiera), fácil de probar con curl, cacheable por URL |
"Contamina" la URI (puristas: la versión no es parte del recurso); duplica documentación; tienta a versionar toda la API por un endpoint |
En la cabecera Accept (negociación de contenido) |
Accept: application/vnd.techcorp.productos.v2+json |
URIs limpias y estables; versión por representación, no por API; permite versionar un solo recurso | Invisible en la URL (difícil de depurar y de cachear); los clientes olvidan la cabecera y reciben la versión por defecto sin saberlo; enrutamiento por cabecera en el gateway |
| Cabecera propia | X-Api-Version: 2 |
Sencilla | No estándar; mismos problemas de invisibilidad |
| Parámetro de query | GET /productos?version=2 |
Fácil de probar | Mezcla versión con filtros; se pierde en enlaces; poco habitual |
| Sin versión (solo evolución compatible) | — | Cero complejidad | No hay salida cuando un cambio incompatible es inevitable |
Elección de TechCorp, alineada con la práctica mayoritaria:
- Versión mayor en la URI:
/v1/productos,/v2/productos. Motivo: es la más visible, la más fácil de enrutar en el gateway de 03-04 (/api/v2/productos→ puede ir incluso a un despliegue distinto) y la que menos errores silenciosos produce en los consumidores (no hay "versión por defecto" que se cuele por olvidar una cabecera). - Solo versiones mayores. No hay
/v1.2/: dentro de/v1/la API solo evoluciona de forma compatible (apartado 2). Una versión nueva es un acontecimiento raro y planificado, no un incremento rutinario. - La versión es por servicio, no global: Catálogo puede estar en
/v2/y Pedidos en/v1/. Cada equipo versiona su contrato. - Todos los contratos de 03-01 pasan a llevar
/v1/:POST /v1/pedidos,GET /v1/productos?ids=,GET /v1/clientes/{id},POST /v1/reservas. En el gateway:/api/v1/pedidos/*→servicio-pedidos:3002/v1/pedidos/*. Hasta ahora lo omitimos por claridad; desde esta lección es parte del contrato.
- Ciclo de vida de una versión
Una versión nueva no sustituye a la anterior de golpe: convive con ella y la retira de forma ordenada.
stateDiagram-v2
[*] --> Activa: publicar /v2/
Activa --> Deprecada: anunciar retirada de /v1/ (cabeceras Deprecation/Sunset)
Deprecada --> Retirada: fecha Sunset alcanzada y tráfico ~0
Retirada --> [*]: /v1/ responde 410 Gone
Etapas y prácticas:
- Publicación de v2. v1 sigue activa y sin cambios. Se anuncia a los consumidores conocidos (canal interno, changelog de la API) con la guía de migración.
- Deprecación de v1. v1 sigue funcionando pero avisa en cada respuesta con dos cabeceras estándar:
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/catalogo/v2/migracion>; rel="successor-version"Deprecation (RFC 9745) dice "esto se va a retirar"; Sunset (RFC 8594) dice cuándo dejará de responder; Link rel="successor-version" apunta a dónde migrar. Los clientes bien hechos registran un aviso al ver Deprecation.
3. Ventana de convivencia. Para consumidores internos de TechCorp, un mínimo de dos ciclos de despliegue de todos los equipos afectados (en la práctica, 4-8 semanas). Para consumidores externos (el ERP de socios), un mínimo de 6 meses por contrato comercial. Durante la ventana, ambas versiones se prueban y se despliegan.
4. Métricas de uso por versión. El gateway (03-04) etiqueta cada petición con su versión y expone un contador (peticiones_total{servicio="catalogo",version="v1"}, en el formato de 06-01). No se retira una versión hasta que su tráfico es cero o los últimos consumidores están identificados y avisados. Sin métricas, retirar es adivinar.
5. Retirada. /v1/* responde 410 Gone (no 404: el recurso existió y se retiró a propósito) con un problema RFC 7807 que enlaza a v2. Tras unas semanas se elimina el código.
Y una regla de coste: cada versión activa es código que mantener, probar y desplegar. Dos versiones a la vez es normal; tres es una señal de que las retiradas no se están haciendo.
- Versionado de eventos
Los eventos son contratos aún más delicados que las APIs, por dos motivos: el productor no sabe quién consume (03-02), así que no puede avisar a nadie en concreto, y los eventos pueden quedarse en una cola (o en una DLQ) durante horas o días y ser procesados por un consumidor más nuevo o más viejo que el productor que los emitió.
Las herramientas:
- El campo
versiondel sobre (02-05):{ eventoId, tipo, version: 1, ocurridoEn, carga }. Es la versión del esquema de la carga de ese tipo de evento. Se incrementa solo en cambios incompatibles de la carga. - Evolución compatible de la carga (misma
version): añadir campos opcionales, añadir valores a listas. Los consumidores tolerantes (apartado 3) no notan nada. Ejemplo: añadircanalVenta: "web" | "movil"apedido.creadoesversion: 1con un campo más. - Cambio incompatible →
version: 2, y el productor publica solo la nueva (publicar las dos duplicaría el procesamiento). Los consumidores deben poder leer ambas mientras haya eventos v1 en circulación. - Upcasting en el consumidor: al recibir un evento, el consumidor lo pasa por una cadena de funciones que convierten cada versión antigua a la siguiente, hasta la actual, y el resto del código solo conoce la última. Es la forma más limpia de soportar N versiones sin
if (version === 1)repartidos por todo el manejador.
// mensajeria/upcasters/pedidoCreado.js (en cada consumidor de pedido.creado)
// Cada función convierte la carga de la versión N a la N+1. Se aplican en cadena.
const upcasters = {
// v1 → v2: en v2 'total' pasó a ser un objeto {importe, moneda} y las líneas llevan 'moneda'
1: (carga) => ({
...carga,
total: { importe: carga.total, moneda: 'EUR' },
lineas: carga.lineas.map(l => ({ ...l, precioUnitario: { importe: l.precioUnitario, moneda: 'EUR' } }))
})
// 2: (carga) => ... cuando exista v3
};
const VERSION_ACTUAL = 2;
function normalizarPedidoCreado(sobre) {
let { version, carga } = sobre;
while (version < VERSION_ACTUAL) {
const subir = upcasters[version];
if (!subir) throw new Error(`No sé convertir pedido.creado v${version}`);
carga = subir(carga);
version++;
}
if (version > VERSION_ACTUAL) {
// Un productor más nuevo que yo: si la carga es un superconjunto compatible, seguir; si no, DLQ (03-02)
console.warn('pedido.creado con versión superior a la conocida', { version, eventoId: sobre.eventoId });
}
return carga; // siempre en la forma de VERSION_ACTUAL
}El manejador de Inventario llama a normalizarPedidoCreado(sobre) y trabaja siempre con la forma v2, reciba lo que reciba.
- Cuándo un evento nuevo en lugar de una versión nueva. Si el significado cambia, no es una versión: es otro evento.
pedido.creadov2 sigue siendo "se ha creado un pedido" con otra forma; si lo que se quiere comunicar es "el pedido ha salido del almacén", eso espedido.enviado, aunque la carga se parezca. Regla: misma semántica y forma distinta → nueva versión; semántica distinta → nuevo tipo. También conviene un evento nuevo cuando la carga cambia tanto que el upcasting sería inventar datos que el productor viejo nunca tuvo.
Y una regla operativa que se deriva de la topología de 03-02: como la routing key es el tipo (pedido.creado), la versión no va en la routing key. Poner pedido.creado.v2 como routing key rompería los bindings de todos los consumidores y les obligaría a suscribirse a cada versión: justo lo contrario de lo que queremos.
- Versionado de Protobuf y de GraphQL
Protocol Buffers (03-03) está diseñado para la evolución compatible, con reglas muy concretas:
- Lo que identifica un campo en el binario es su número, no su nombre. Renombrar
producto_idaid_productoes compatible (solo cambia el código generado); cambiar el número no lo es y reutilizar un número borrado es catastrófico: los mensajes viejos se interpretarían con el tipo/significado nuevo. - Añadir un campo con un número nuevo es compatible: los receptores viejos lo ignoran, los nuevos ven el valor por defecto en mensajes viejos.
- Eliminar un campo: se borra del
.protoy su número (y su nombre) se marcan comoreservedpara que nadie los reutilice. - Cambiar el tipo solo es seguro entre tipos compatibles en el cable (
int32/int64/bool,string/bytescon UTF-8); en la práctica, trátalo como incompatible. - Cambios incompatibles de servicio (firma de un
rpc) → unpackagenuevo (techcorp.inventario.v2) que convive con el anterior.
message SolicitudReserva {
string pedido_id = 1;
repeated LineaReserva lineas = 2;
int32 expira_en_segundos = 3;
// Se eliminó 'string almacen = 4' en 2026-09: nunca reutilizar el número ni el nombre
reserved 4;
reserved "almacen";
string canal_venta = 5; // añadido en 2026-09, compatible
}GraphQL (03-03) toma el camino opuesto al versionado: no hay versiones del esquema. La filosofía es la evolución continua del grafo:
- Añadir tipos, campos, argumentos opcionales y valores de
enumes compatible (los clientes piden solo lo que conocen: el over-fetching nulo hace que un campo nuevo no llegue a nadie que no lo pida). - Un campo que hay que retirar se marca con
@deprecated(reason: "..."); las herramientas de los clientes lo muestran, y el servidor puede medir con exactitud quién lo pide todavía (cada consulta declara sus campos), lo que hace la retirada mucho más segura que en REST. - Los cambios de tipo se hacen añadiendo un campo nuevo (
precioDetallado: Precio!) y deprecando el viejo.
type Producto {
id: ID!
nombre: String!
precio: Float! @deprecated(reason: "Usa precioDetallado; se retira el 2027-02-28")
precioDetallado: Precio!
moneda: String! @deprecated(reason: "Incluida en precioDetallado")
}
type Precio { importe: Float!, moneda: String! }
- Contrato primero: OpenAPI y AsyncAPI
Escribir el contrato antes que el código (design-first, contract-first) cambia la dinámica entre equipos: Catálogo y Pedidos acuerdan el YAML de GET /v1/productos en una hora, y desde ese momento uno implementa el servidor, el otro el cliente (con un mock generado del contrato) y se encuentran en integración con la forma ya pactada. El contrato es también donde se revisa un cambio: un pull request al YAML es el lugar natural para que un consumidor diga "esto me rompe".
Para REST ya vimos OpenAPI 3 (03-01). Para eventos existe su equivalente: AsyncAPI, que describe canales (en RabbitMQ, exchanges y routing keys), mensajes y sus esquemas. Fragmento para pedido.creado:
asyncapi: 3.0.0
info:
title: TechCorp - Eventos de Pedidos
version: 1.2.0 # versión del DOCUMENTO; la del esquema de cada evento va en el sobre
servers:
rabbitmq:
host: rabbitmq:5672
protocol: amqp
channels:
pedidoCreado:
address: pedido.creado # routing key en el exchange techcorp.eventos (03-02)
messages:
pedidoCreado:
$ref: '#/components/messages/PedidoCreado'
bindings:
amqp:
is: routingKey
exchange: { name: techcorp.eventos, type: topic, durable: true }
operations:
publicarPedidoCreado:
action: send
channel: { $ref: '#/channels/pedidoCreado' }
summary: Pedidos publica este evento al aceptar un pedido (POST /v1/pedidos → 202)
components:
messages:
PedidoCreado:
name: pedido.creado
contentType: application/json
payload:
$ref: '#/components/schemas/SobrePedidoCreado'
schemas:
SobrePedidoCreado:
type: object
required: [eventoId, tipo, version, ocurridoEn, carga]
properties:
eventoId: { type: string, example: evt-3f9c... }
tipo: { type: string, const: pedido.creado }
version: { type: integer, example: 1 }
ocurridoEn: { type: string, format: date-time }
carga:
type: object
required: [pedidoId, clienteId, cliente, direccionEnvio, lineas, total]
properties:
pedidoId: { type: string, example: ped-88213 }
clienteId: { type: string, example: c-1024 }
cliente:
type: object
required: [email, nombre]
properties:
email: { type: string, format: email }
nombre: { type: string }
direccionEnvio: { $ref: '#/components/schemas/Direccion' }
lineas:
type: array
minItems: 1
items:
type: object
required: [productoId, nombre, cantidad, precioUnitario]
properties:
productoId: { type: string }
nombre: { type: string }
cantidad: { type: integer, minimum: 1 }
precioUnitario: { type: number }
total: { type: number, example: 79.70 }
canalVenta: { type: string, enum: [web, movil], description: "Añadido en v1 (compatible, opcional)" }Cómo se usa: el documento vive en el repositorio de Pedidos (el productor), los consumidores lo leen para generar validadores o stubs, y cualquier cambio pasa por revisión. Igual que con OpenAPI, cada servicio publica su AsyncAPI para los eventos que produce.
- Tests de contrato dirigidos por el consumidor (introducción)
Documentar el contrato no garantiza cumplirlo. Los tests de contrato dirigidos por el consumidor (consumer-driven contract tests, con Pact como herramienta de referencia) cierran ese hueco:
- El consumidor (Pedidos) escribe, en sus propias pruebas, las interacciones que espera del productor: "cuando pida
GET /v1/productos?ids=p-501espero200con un objeto que tengadatos[0].id,nombre(cadena),precio(número) ydisponible(booleano)". Solo los campos que usa, no toda la respuesta (tolerancia, otra vez). - De esas pruebas se genera un fichero de pacto (JSON) que se publica en un broker de pactos.
- En el CI del productor (Catálogo), se descargan los pactos de todos sus consumidores y se verifican contra el servicio real: si Catálogo cambia
precioa objeto, el pacto de Pedidos falla en el CI de Catálogo, antes de desplegar.
Lo valioso: el productor sabe exactamente qué campos usa cada consumidor (puede retirar sin miedo lo que nadie pacta) y un cambio incompatible se detecta donde se origina. Se implementan en 04-05; aquí basta con saber que existen y que son la red de seguridad de todo lo anterior.
- Ejemplo guiado: evolucionar
GET /productos
GET /productosSituación inicial (03-01, ahora con /v1/):
{ "datos": [ { "id": "p-501", "nombre": "Auriculares BT X200", "precio": 59.90, "moneda": "EUR", "disponible": true } ], "noEncontrados": [] }Consumidores: Pedidos (traductorProducto: usa id, nombre, precio, disponible), el BFF móvil (usa además imagenUrl) y el ERP de dos socios.
Paso 1: añadir atributos (compatible)
El catálogo en MongoDB ya guarda atributos (02-04) y la web quiere mostrarlos. Cambio: añadir un campo opcional en la respuesta.
{ "id": "p-501", "nombre": "Auriculares BT X200", "precio": 59.90, "moneda": "EUR", "disponible": true,
"atributos": { "color": "negro", "conexion": "Bluetooth 5.3", "autonomiaHoras": 30 } }Procedimiento: (1) pull request al OpenAPI de Catálogo añadiendo atributos como object opcional con additionalProperties; (2) los consumidores no hacen nada (lectores tolerantes: Pedidos lo ignora, el BFF lo usa cuando quiera); (3) se despliega en /v1/; (4) los pactos de Pedidos y del BFF siguen pasando porque solo comprueban los campos que usan. Coste para los demás equipos: cero. Esto es lo que debería ser el 95 % de los cambios.
Paso 2: precio pasa de número a objeto {importe, moneda} (incompatible)
TechCorp va a vender en Portugal y Reino Unido; un precio sin moneda pegada es fuente de errores, y moneda como campo hermano se olvida. Se decide que precio sea { "importe": 59.90, "moneda": "EUR" }. Cambiar el tipo de un campo es incompatible: Number(dto.precio) en Pedidos daría NaN y el ERP de un socio sumaría objetos.
Hay una opción compatible que se considera primero: añadir precioDetallado: {importe, moneda} y mantener precio numérico para siempre. Es lo que haría GraphQL. Catálogo la descarta por dos motivos legítimos: quedarían dos campos con el mismo dato (fuente de inconsistencias) y precio numérico sin moneda es justamente el modelo que se quiere prohibir. Así que v2.
Plan:
- Diseño de v2 (semana 0): OpenAPI de
/v2/productosconpreciocomo objeto y sinmonedasuelta; se aprovecha para dejarimagenUrlobligatorio (otro cambio incompatible que se agrupa en la misma versión: las versiones mayores son caras, mejor pocas y con varios cambios). Revisión con Pedidos, BFF y los socios. - Implementación (semanas 1-2): Catálogo sirve
/v1/y/v2/desde el mismo código; internamente el modelo es el nuevo y una capa de traducción hacia atrás genera la forma v1 (precio: importe,moneda). Así v1 no es una rama de código congelada, sino una vista. - Publicación de v2 y deprecación de v1 (semana 2):
/v1/productosempieza a responder conDeprecation: true,Sunseta 6 meses (por los socios externos) yLinka la guía de migración. El gateway añade la ruta/api/v2/productos/*y etiqueta las métricas por versión. - Migración de Pedidos (semanas 3-4), el consumidor interno crítico:
// traductores/traductorProducto.js — versión que consume /v2/productos
function aProductoDePedidos(dto) {
return {
productoId: dto.id,
nombre: dto.nombre,
precioUnitario: Number(dto.precio.importe),
moneda: dto.precio.moneda, // Pedidos empieza a guardar la moneda en lineas_pedido (nueva columna, opcional)
disponible: dto.disponible !== false
};
}La migración es: cambiar CATALOGO_URL de base /v1 a /v2 (o la ruta en el cliente), actualizar el traductor, actualizar el pacto de Pedidos contra /v2/, y una migración de esquema en Pedidos (columna moneda, por defecto EUR, compatible). Se despliega con la estrategia de 05-04 y se vigila el ratio de errores de POST /pedidos.
5. Migración del BFF móvil (semana 4) y aviso formal a los socios (mes 1) con la fecha Sunset.
6. Seguimiento (meses 2-6): el panel de 06-01 muestra peticiones_total{version="v1"} bajando. Al mes 5, un socio sigue en v1: se le contacta directamente (las métricas por token de cliente dicen quién es).
7. Retirada (mes 6): /v1/productos responde 410 Gone con un problema RFC 7807 (codigo: VERSION_RETIRADA, detail con la URL de v2). Un mes después se borra la capa de traducción hacia atrás.
Lo que ha hecho posible que un cambio de tipo en el campo más usado de la API no cause ni un incidente: contrato escrito y revisado antes de codificar, consumidores tolerantes que solo dependen de lo que usan, versión mayor en la URI con dos versiones conviviendo, cabeceras de deprecación, métricas por versión y pactos que habrían fallado en el CI si alguien se hubiera saltado el orden.
Errores Comunes y Consejos
- Validar estrictamente la respuesta ajena ("rechaza si hay campos desconocidos"). Convierte cada campo nuevo del productor en una rotura del consumidor. Lector tolerante siempre.
- Cambiar semántica sin cambiar forma. El cambio más peligroso porque ninguna prueba automática lo ve. Si
preciopasa a incluir IVA, es una versión nueva (o un campo nuevo), aunque siga siendo un número. - Versionar por costumbre (
/v1.3/,/v1.4/). Cada versión es coste; dentro de una versión mayor solo evolución compatible. - Poner la versión en la routing key de los eventos. Rompe todos los bindings. La versión va en el sobre.
- Publicar un evento en dos versiones a la vez. Duplica el procesamiento en todos los consumidores. Se publica la nueva; los consumidores hacen upcasting.
- Reutilizar números de campo en Protobuf.
reservedsiempre al borrar. - Retirar una versión sin métricas. "Nadie usa v1" es una hipótesis hasta que un contador lo confirma.
- Cabecera
DeprecationsinSunset. Un aviso sin fecha no mueve a nadie. - Contrato escrito después del código. Se desactualiza en la primera iteración y deja de servir para revisión. Primero el YAML, luego el código, y el CI que compruebe que coinciden.
- Documentar solo lo que devuelves hoy sin decir qué es contrato y qué no. Deja claro en OpenAPI/AsyncAPI que los campos no documentados no existen y que el orden no importa.
Ejercicios
Ejercicio 1. Clasifica cada cambio propuesto en la API de Pedidos como compatible o incompatible, indica qué haría el equipo (desplegar en /v1/, añadir campo, nueva versión) y qué consumidor de TechCorp podría romperse: (a) POST /v1/pedidos acepta un nuevo campo opcional cupon; (b) GET /v1/pedidos/{id} deja de devolver historial porque es caro de calcular; (c) el estado PAGADO se renombra a COBRADO; (d) se añade el estado EN_REPARTO a la máquina de estados; (e) total pasa a incluir gastos de envío; (f) Idempotency-Key pasa de obligatoria a opcional.
Ejercicio 2. El equipo de Pedidos quiere añadir a pedido.creado un bloque facturacion: { nif, razonSocial } para clientes empresa (opcional) y, además, cambiar direccionEnvio.pais de código ISO de dos letras ("ES") a nombre completo ("España"). Decide para cada cambio si es una nueva version del sobre, y en caso afirmativo escribe el upcaster que necesitaría Notificaciones (que imprime el país en el correo) para pasar de la versión antigua a la nueva. Después argumenta si el segundo cambio debería hacerse siquiera.
Ejercicio 3. Un socio externo consume GET /v1/productos desde su ERP y, seis semanas antes de la fecha Sunset, avisa de que no llegará a migrar. Propón tres opciones (con pros y contras) que TechCorp podría ofrecer sin romper la regla de "una versión activa por servicio a largo plazo", y di cuál recomendarías.
Soluciones
Solución 1.
| Cambio | ¿Compatible? | Acción | Quién se rompe si se hace mal | |
|---|---|---|---|---|
| a | Campo opcional cupon en la petición |
Sí | Desplegar en /v1/; documentar en OpenAPI |
Nadie |
| b | Eliminar historial de la respuesta |
No | Alternativas compatibles: mantenerlo y calcularlo bajo demanda con ?incluir=historial (nuevo parámetro opcional, por defecto con historial para no romper), o moverlo a GET /v1/pedidos/{id}/historial manteniendo el campo hasta una v2 |
La web (muestra la línea de tiempo del pedido) |
| c | Renombrar PAGADO → COBRADO |
No (cambia un valor de enum que los clientes comparan) |
No hacerlo; si es imprescindible, v2 | Web, BFF móvil, cualquier switch de estados |
| d | Añadir EN_REPARTO |
Formalmente incompatible (nuevo valor de enum) |
Se puede hacer en /v1/ si el contrato ya decía "pueden aparecer estados nuevos; trátalos con un caso por defecto" y los consumidores lo cumplen; avisar y comprobar el BFF y la web antes |
Front-ends con mapeo exhaustivo de estados |
| e | total incluye envío |
No (cambio de semántica) | Añadir totalConEnvio y gastosEnvio como campos nuevos y dejar total como estaba; o v2 |
Pedidos↔Pagos (el importe a cobrar), el ERP de socios, contabilidad: y sin ningún error visible |
| f | Idempotency-Key pasa a opcional |
Sí (relajar una validación) | Desplegar en /v1/ (aunque es mala idea de diseño: 02-05 la quiere obligatoria) |
Nadie se rompe; se pierde una garantía |
Solución 2.
facturacionopcional: compatible, mismaversion: 1. Los consumidores tolerantes lo ignoran; Notificaciones podrá usarlo para el correo cuando quiera. Se documenta en el AsyncAPI.paisde"ES"a"España": incompatible (cambia el formato/semántica de un campo existente):version: 2. Upcaster de Notificaciones:
const NOMBRES_PAIS = { ES: 'España', PT: 'Portugal', GB: 'Reino Unido', FR: 'Francia' };
const upcasters = {
1: (carga) => ({
...carga,
direccionEnvio: { ...carga.direccionEnvio, pais: NOMBRES_PAIS[carga.direccionEnvio.pais] ?? carga.direccionEnvio.pais }
})
};¿Debería hacerse? No. El código ISO es el formato interoperable (lo usa Clientes, lo usan las pasarelas, lo usan los transportistas); un nombre en castellano no sirve para nada más que para imprimir, y eso es responsabilidad de presentación de Notificaciones (que puede tener su propia tabla de nombres, o recibir paisNombre como campo adicional compatible). Se rompería la compatibilidad, se obligaría a todos los consumidores a upcastear, y se perdería información (el código) por comodidad de uno. La respuesta correcta al equipo de Pedidos: añade direccionEnvio.paisNombre opcional si de verdad hace falta, y sigue en version: 1.
Solución 3.
- Ampliar el
Sunsetde v1 para todos (p. ej. 3 meses más). Pros: sencillo, sin excepciones. Contras: mantiene el coste de dos versiones para todos por un solo consumidor; sienta precedente. - Excepción por cliente: v1 sigue respondiendo solo para el token de ese socio (el gateway enruta por identidad; para el resto,
410). Pros: la ventana se cierra para todos los demás; el coste se acota; presiona al socio con una fecha nueva y firme. Contras: lógica de excepción en el gateway/servicio; hay que retirarla después. - Adaptador temporal para el socio: un pequeño BFF (03-04) o middleware que traduce v2 → forma v1 (
precio.importe→precio) para su ERP, desplegado por TechCorp o entregado al socio como librería. Pros: Catálogo retira v1 en fecha; la traducción es trivial. Contras: es un componente más; solo vale si la traducción es mecánica (aquí lo es).
Recomendación: la 2 con fecha límite corta y no negociable, o la 3 si la relación comercial lo justifica y la traducción es tan simple como en este caso. La 1 penaliza a todos por uno. En cualquier caso, la métrica por versión y por cliente es lo que permite tomar la decisión con datos y no con suposiciones.
Conclusión
El contrato es la única frontera entre equipos y, bien gestionada, la que permite que cada uno despliegue a su ritmo. Hemos separado los cambios compatibles (añadir campos opcionales, endpoints, eventos) de los incompatibles (renombrar, cambiar tipo, eliminar, endurecer validaciones y, el más traicionero, cambiar la semántica), y hemos visto que la regla de tolerancia (consumidor que ignora lo desconocido, productor que solo añade) absorbe la gran mayoría de la evolución sin versionar nada. Para el resto: versión mayor en la URI (/v1/, /v2/) con evolución compatible dentro de cada una, ciclo de vida con Deprecation/Sunset, ventana de convivencia y métricas de uso antes de retirar; campo version del sobre y upcasting en los consumidores para los eventos (y un evento nuevo cuando cambia el significado); números de campo y reserved en Protobuf; @deprecated por campo y sin versiones en GraphQL; contrato primero con OpenAPI y AsyncAPI; y los tests de contrato dirigidos por el consumidor como red de seguridad. El ejemplo de GET /productos ha recorrido el camino completo, desde añadir atributos sin que nadie se entere hasta cambiar precio a {importe, moneda} con seis meses de convivencia y la migración ordenada de Pedidos.
Con esto termina el módulo de comunicación: sabemos diseñar APIs REST con sus contratos, códigos y errores uniformes; publicar y consumir eventos en RabbitMQ con garantías at-least-once; cuándo recurrir a gRPC o GraphQL; qué hace y qué no hace el API Gateway del puerto 8080 y por qué existen los BFF; cómo se encuentran y balancean los servicios con el DNS de Kubernetes y los health checks; y cómo evolucionan todos esos contratos sin romper a nadie. Lo que todavía no existe es el código de un servicio completo: hasta ahora hemos escrito rutas, publicadores y consumidores sueltos. En el módulo 4 elegiremos las herramientas concretas del stack, montaremos un microservicio desde cero (estructura de proyecto, configuración, arranque, salud), lo conectaremos de verdad a los demás servicios y a RabbitMQ siguiendo estos contratos, y le pondremos pruebas unitarias, de integración y de contrato con Pact. Empieza por la elección de tecnologías y herramientas.
Curso de Microservicios
Módulo 1: Introducción a los Microservicios
- Conceptos Básicos de Microservicios
- Ventajas y Desventajas de los Microservicios
- Comparación con la Arquitectura Monolítica
- Cuándo Adoptar Microservicios: Criterios de Decisión
- El Caso Práctico del Curso: la Tienda Online de TechCorp
Módulo 2: Diseño de Microservicios
- Principios de Diseño de Microservicios
- Descomposición de Aplicaciones Monolíticas
- Definición de Bounded Contexts
- Gestión de Datos: una Base de Datos por Servicio
- Consistencia Distribuida: Sagas, CQRS y Event Sourcing
Módulo 3: Comunicación entre Microservicios
- APIs RESTful
- Mensajería Asíncrona
- Protocolos de Comunicación: gRPC, GraphQL
- API Gateway y Backend for Frontend
- Descubrimiento de Servicios y Balanceo de Carga
- Contratos y Versionado de APIs
Módulo 4: Implementación de Microservicios
- Elección de Tecnologías y Herramientas
- Desarrollo de un Microservicio Simple
- Gestión de Configuración
- Integración Práctica: Consumir APIs y Publicar Eventos
- Pruebas en Microservicios: Unitarias, de Integración y de Contrato
Módulo 5: Despliegue y Orquestación
- Contenedores y Docker
- Orquestación con Kubernetes
- CI/CD para Microservicios
- Estrategias de Despliegue: Rolling, Blue-Green y Canary
- Service Mesh: Istio y Linkerd
Módulo 6: Monitoreo y Mantenimiento
- Monitoreo y Logging
- Trazabilidad Distribuida con OpenTelemetry
- Gestión de Errores y Recuperación
- Escalabilidad y Rendimiento
- SLOs, Alertas y Gestión de Incidentes
Módulo 7: Seguridad en Microservicios
- Autenticación y Autorización
- Seguridad en la Comunicación
- Prácticas de Seguridad
- Seguridad en Contenedores y Kubernetes
