Versionado de contratos JSON: compatibilidad, deprecación y migración de datos
En resumen: Añadir un campo no es automáticamente seguro. Cubre los límites de compatibilidad, los campos de versión, las ventanas de deprecación, la migración con escritura dual, las pruebas de contrato y las métricas de canary.

Conclusión: ejecuta la evolución de versiones como expand, backfill y luego contract, y persiste la versión dentro del documento; confirma que los consumidores toleran campos y valores de enum desconocidos antes de publicar nada, y lleva la deprecación en este orden: congelar, anunciar, observar y eliminar.
La parte difícil del versionado no es cambiar el código; es que no puedes saber quién sigue leyendo el campo antiguo. La compatibilidad nunca es una promesa unidireccional: es una alineación continua entre el publicador y consumidores desconocidos.
1. Límites de compatibilidad: lo aditivo no es automáticamente seguro
Los manuales llaman a un campo opcional nuevo "compatible hacia atrás", pero eso solo se cumple cuando los consumidores toleran campos desconocidos. Añadir un campo sigue rompiendo clientes en al menos tres situaciones:
- el consumidor valida con un schema que establece
additionalProperties: false; - el consumidor recorre el objeto en un orden fijo, o lee valores por posición en lugar de por clave;
- el campo nuevo reutiliza un nombre antiguo pero cambia su significado, por ejemplo ampliando
statusde "estado del pedido" a "estado de cumplimiento".
Una adición segura necesita tres precondiciones: el campo es opcional, su significado es independiente, y los consumidores ignoran las claves desconocidas. Confirma las tres antes de hablar siquiera de un número de versión.
{
"schemaVersion": 2,
"orderId": "ord-8842",
"status": "paid",
"refundDeadline": "2026-11-30T00:00:00+08:00",
"channel": "wechat"
}
Aquí refundDeadline y channel son adiciones opcionales de la v2, mientras que orderId y status conservan su semántica original. schemaVersion se persiste con el documento en lugar de vivir solo en una cabecera HTTP.
2. Cambios incompatibles que no puedes publicar
Trata los siguientes cambios como líneas rojas que exigen una versión nueva o una transición con escritura dual. Actualizar la documentación no te salvará:
| Cambio | Por qué rompe la compatibilidad | Alternativa aceptable |
|---|---|---|
| Eliminar un campo | Los consumidores leen undefined de inmediato |
Deprecar, observar y eliminar después de la ventana |
| Cambiar el tipo de un campo | "1" se convierte en 1; los clientes fuertemente tipados fallan directamente y los débilmente tipados a menudo fallan la validación |
Añadir un nombre de campo nuevo y escritura dual durante la transición |
| Añadir un campo obligatorio | Los llamadores antiguos no lo envían, así que la validación siempre falla | Proporcionar un valor por defecto, o hacerlo obligatorio solo en la versión nueva |
| Cambiar el conjunto de valores de enum | Las sentencias switch del consumidor carecen de rama de fallback |
Enseñar primero a los clientes a aceptar valores desconocidos y luego ampliar el enum |
| Cambiar el significado de un campo | Los tipos siguen siendo válidos mientras el comportamiento cambia: el caso más difícil de depurar | Tratarlo como un campo nuevo y congelar la semántica antigua |
| Cambiar la profundidad de anidamiento | Todas las rutas de acceso tienen que cambiar | Mantener la forma antigua y añadir campos planos o un parámetro expand |
El cambio de significado es la fila más peligrosa: la validación del schema sigue verde, la monitorización no informa errores, y solo las métricas de negocio se desvían. Cualquier cambio de significado debe publicarse como un campo nuevo.
3. Dónde vive la versión: documento, URL, tipo de medio
Las tres ubicaciones comunes sirven a propósitos distintos:
| Ubicación | Ejemplo | Bueno para | Limitación |
|---|---|---|---|
| Campo del documento | "schemaVersion": 2 |
Bases de datos, colas de mensajes, copias de seguridad offline | La capa de transporte no puede enrutar por él |
| Ruta de URL | /v2/orders/8842 |
API públicas | Acopla las direcciones de recursos a las versiones y encarece la migración |
| Tipo de medio | application/vnd.acme.order.v2+json |
Servicios internos que necesitan negociación de contenido | Los llamadores deben establecer Accept correctamente |
Una división práctica del trabajo: usa negociación de tipo de medio en el cable, y persiste siempre un campo de versión en el documento. Los mensajes antiguos en una cola no se actualizarán porque hayas cambiado una URL, y los archivos de copia de seguridad tampoco. Solo un schemaVersion dentro del documento da a la repetición, el backfill y las auditorías una base.
GET /orders/ord-8842 HTTP/1.1
Host: api.bbdrug.com
Accept: application/vnd.acme.order.v2+json
Si versionas la URL, al menos mantén la ruta antigua devolviendo la forma antigua durante toda la ventana de deprecación, en lugar de apuntarla en silencio a la implementación nueva. Para decisiones más amplias de contrato de API, consulta Puntos clave para diseñar contratos de datos de API JSON.
4. Abrir una ventana de deprecación
La deprecación no es un único anuncio; es una ventana con un inicio, un periodo de observación y un final. Un ritmo viable:
- Congelar: deja de escribir el campo antiguo, pero sigue leyéndolo.
- Anunciar: marca la fecha de eliminación en los documentos, el changelog y una cabecera de respuesta al mismo tiempo.
- Observar: cuenta las lecturas del campo antiguo por llamador hasta que se mantenga en cero durante dos periodos consecutivos.
- Eliminar: solo después de que cierre la ventana, las lecturas sean cero y pasen las pruebas de regresión, elimina el campo y su código de compatibilidad.
La cabecera de respuesta Sunset (RFC 8594) da a los clientes una fecha de eliminación legible por máquinas. Deprecation sigue siendo un borrador de IETF: utilizable, pero no asumas que todos los clientes lo entienden. Combínala con una cabecera Link que apunte a la guía de migración:
Deprecation: true
Sunset: Wed, 31 Mar 2027 23:59:59 GMT
Link: <https://bbdrug.com/docs/deprecations/order-status>; rel="deprecation"; type="text/html"
La longitud de la ventana es una convención de equipo. Un suelo común es un ciclo de release o 90 días, el que sea mayor. Las API públicas deberían tener más, los servicios internos pueden tener menos, pero nunca menos que el tiempo que cada llamador necesita para actualizarse.
5. Encontrar consumidores antes de deprecar
Antes de deprecar, debes poder responder "¿quién sigue leyendo esto?". De lo contrario, la ventana es autoengaño. Ordena la evidencia por fiabilidad:
- Logs del gateway: agrega el acceso al campo antiguo por
appId,User-Agenty versión del cliente. - Trazas distribuidas: etiqueta el campo antiguo en la capa de serialización para que una traza muestre directamente el nombre del servicio llamador.
- Registro de consumidores de colas: la lista de grupos de consumidores es más fiable que una búsqueda en el código.
- Búsqueda en el repositorio:
rgsolo cubre tu propio código; los llamadores externos siguen requiriendo logs.
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats
Un acierto de búsqueda prueba que "alguien en este repositorio lo lee", no que "nadie más lo hace". Exporta los logs del gateway día a día y confirma que las lecturas del campo antiguo se han mantenido en cero antes de eliminar nada.
6. Migración de datos: expand, backfill, contract
Parallel Change de Martin Fowler describe tres pasos: expand (los campos antiguo y nuevo coexisten y ambas versiones pueden leerse), backfill (mover los registros históricos a la forma nueva) y contract (eliminar el campo antiguo y las ramas de compatibilidad). No inviertas el orden, o no tendrás ruta de rollback.
// Esqueleto de migración: cursor por clave primaria, escrituras condicionales para idempotencia
async function migrateBatch(collection, cursor, batchSize = 500) {
const records = await collection
.find({ _id: { $gt: cursor } })
.limit(batchSize)
.toArray();
if (records.length === 0) {
return { nextCursor: cursor, done: true };
}
const operations = records
.filter((record) => record.schemaVersion < 2)
.map((record) => ({
updateOne: {
filter: { _id: record._id, schemaVersion: record.schemaVersion },
update: {
$set: {
schemaVersion: 2,
status: record.legacyStatus ?? record.status,
refundDeadline: record.refundDeadline ?? null,
},
},
},
}));
if (operations.length > 0) {
await collection.bulkWrite(operations);
}
return { nextCursor: records.at(-1)._id, done: false };
}
Detalles fáciles de pasar por alto:
- Idempotencia: incluye el número de versión antiguo en el
filter, para que reejecutar nunca reescriba un registro ya migrado. - Lotes y checkpoints: avanza por cursor
_id, guarda un checkpoint y reanuda desde él tras un fallo. - Ensayo: ejecuta el script contra una instantánea de producción y compara recuentos de registros y checksums antes y después.
- Rollback: mantén el campo antiguo hasta que el campo nuevo pase la validación; cambia las rutas de lectura con un flag y nunca hagas rollback editando solo
schemaVersion. - Validación: ejecuta las restricciones de negocio después de la migración (importes no negativos, transiciones de estado legales), no solo una comprobación de análisis JSON.
Los datos de configuración siguen el mismo proceso; consulta Gestión de JSON para configuración, fixtures y datos locales para saber cómo organizar esos archivos.
7. Pruebas de contrato: lleva la compatibilidad a CI
La compatibilidad no puede depender de la memoria en la revisión de código; necesita pruebas. Dos capas bastan:
- Pruebas de forma: valida las respuestas con JSON Schema, pero no establezcas
additionalProperties: falseen los schemas que usan los consumidores. - Pruebas del subconjunto del consumidor: extrae los campos en los que el consumidor antiguo realmente se apoya en un sub-schema y verifica que las respuestas nuevas todavía lo satisfacen.
// Prueba de contrato: las respuestas nuevas deben satisfacer todavía el subconjunto del consumidor antiguo
import Ajv from 'ajv';
import assert from 'node:assert/strict';
import { test } from 'node:test';
const ajv = new Ajv({ strict: false });
const validateLegacySubset = ajv.compile(legacyConsumerSubsetSchema);
test('a v2 response still satisfies the v1 consumer subset', () => {
const response = buildOrderResponse({ schemaVersion: 2 });
const { schemaVersion, ...legacyFields } = response;
const isValid = validateLegacySubset(legacyFields);
assert.equal(isValid, true, JSON.stringify(validateLegacySubset.errors));
});
El sub-schema declara solo los campos y tipos que lee el consumidor antiguo y deja pasar cada campo nuevo. Eso detecta campos eliminados y cambios de tipo sin falsas alarmas por adiciones. Mantén los datos de casos en un directorio de fixtures, ordenados y comparables con diff; para conflictos de merge en esos archivos, consulta Conflictos de merge de Git en archivos JSON: union escribe JSON roto, haz merge por clave.
8. Despliegue canary: escala por dimensión, detente por métricas
Los problemas de compatibilidad rara vez aparecen en las pruebas unitarias; aparecen en un canary. Amplía la exposición de lo estrecho a lo amplio:
| Dimensión | Práctica | Qué valida |
|---|---|---|
| Lista blanca de tenants | Activar la ruta de lectura nueva primero para clientes internos o afines | Diferencias de forma de los datos del mundo real |
| Versión del cliente | Activar solo para clientes ya actualizados a la forma nueva | Clientes antiguos y nuevos corriendo en paralelo |
| Porcentaje de tráfico | 5 % → 25 % → 100 %, observando un ciclo de negocio completo por paso | Llamadas de cola larga y comportamiento de aciertos de caché |
Vigila al menos tres métricas: tasa de error de la API, tasa de fallo de validación de contrato, y ratio de lectura entre campos antiguos y nuevos. Define condiciones de parada automáticas, como 5xx por encima del 1 % o un rebote repentino en las lecturas del campo antiguo, y haz rollback del flag. Mantén las escrituras duales durante el canary para que el rollback solo requiera cambiar la ruta de lectura, no reparar datos.
9. Una lista de comprobación de compatibilidad antes de publicar
- [ ] Los campos nuevos son opcionales, semánticamente independientes y no reutilizan nombres antiguos.
- [ ] Los consumidores manejan campos desconocidos y valores de enum desconocidos con un fallback.
- [ ] El campo de versión se persiste con el documento y es negociable en el cable.
- [ ] La ventana de deprecación tiene un anuncio, una cabecera
Sunsety observación. - [ ] Las lecturas del campo antiguo pueden rastrearse hasta llamadores concretos mediante logs o trazas.
- [ ] El script de migración es idempotente, por lotes, reanudable y tiene ruta de rollback.
- [ ] Las pruebas de contrato cubren el subconjunto del consumidor heredado y se ejecutan en CI.
- [ ] El canary tiene dimensiones de exposición explícitas y métricas de parada.
FAQ
¿Añadir un campo opcional siempre es compatible hacia atrás?
No. Solo es compatible cuando los consumidores ignoran los campos desconocidos y evitan la validación estricta de schema. Un consumidor que usa additionalProperties: false o que lee por posición seguirá fallando. Verifica su tolerancia con una prueba de contrato antes de publicar.
¿schemaVersion debe vivir en la URL o en el cuerpo de la respuesta?
El cable puede negociar versiones mediante tipos de medio o rutas de URL, pero los registros almacenados deben llevar schemaVersion dentro del documento. Las colas, las copias de seguridad offline y las auditorías nunca pasan por una URL, así que solo una versión dentro del documento puede sostener la repetición y el backfill.
¿Un campo deprecado puede eliminarse de inmediato?
No. Deja de escribirlo, anuncia la fecha de eliminación y cuenta las lecturas por llamador. Elimínalo solo después de que cierre la ventana y las lecturas se mantengan en cero. Antes de la eliminación, ejecuta una regresión completa para confirmar que ningún cron job ni informe oculto lo sigue leyendo.
¿Y si un script de migración falla a mitad?
Diséñalo como un trabajo idempotente: actualizaciones condicionales, un cursor por clave primaria y checkpoints. Reanuda desde el checkpoint tras un fallo, y las ejecuciones repetidas no deben escribir dos veces. Para el rollback, mantén el campo antiguo y una instantánea; no te limites a revertir schemaVersion.
¿Cómo debo secuenciar una migración de datos?
Sigue Parallel Change: primero expand para que los campos antiguo y nuevo coexistan y ambas versiones puedan leerse, luego backfill de los registros históricos a la forma nueva, y después contract eliminando el campo antiguo y las ramas de compatibilidad. No inviertas el orden, o pierdes la ruta de rollback, y haz que el script de migración sea idempotente, por lotes y reanudable.