Versionando contratos JSON: compatibilidade, depreciação e migração de dados
Resumo: Adicionar um campo não é automaticamente seguro. Cobre fronteiras de compatibilidade, campos de versão, janelas de depreciação, migração com gravação dupla, testes de contrato e métricas de canário.

Resumo da ópera: conduza a evolução de versão como expandir, preencher e contrair, e persista a versão dentro do documento; confirme que os consumidores toleram campos e valores de enum desconhecidos antes de publicar qualquer coisa, e passe a depreciação por congelar, anunciar, observar e remover nessa ordem.
A parte difícil do versionamento não é mudar código; é que você não consegue saber quem ainda lê o campo antigo. Compatibilidade nunca é uma promessa de mão única — é um alinhamento contínuo entre o publicador e consumidores desconhecidos.
1. Fronteiras de compatibilidade: aditivo não é automaticamente seguro
Os livros chamam um novo campo opcional de "retrocompatível", mas isso só vale quando os consumidores toleram campos desconhecidos. Adicionar um campo ainda quebra clientes em pelo menos três situações:
- o consumidor valida com um schema que define
additionalProperties: false; - o consumidor percorre o objeto em uma ordem fixa, ou lê valores por posição em vez de por chave;
- o novo campo reutiliza um nome antigo mas muda seu significado, por exemplo ampliando
statusde "status do pedido" para "status de fulfillment".
Uma adição segura precisa de três pré-condições: o campo é opcional, seu significado é independente e os consumidores ignoram chaves desconhecidas. Confirme as três antes mesmo de discutir um número de versão.
{
"schemaVersion": 2,
"orderId": "ord-8842",
"status": "paid",
"refundDeadline": "2026-11-30T00:00:00+08:00",
"channel": "wechat"
}
Aqui refundDeadline e channel são adições opcionais da v2, enquanto orderId e status mantêm suas semânticas originais. schemaVersion é persistido com o documento em vez de viver apenas em um cabeçalho HTTP.
2. Mudanças disruptivas que você não pode publicar
Trate as seguintes mudanças como linhas vermelhas que exigem uma nova versão ou uma transição com gravação dupla. Atualizar a documentação não vai salvar você:
| Mudança | Por que quebra a compatibilidade | Alternativa aceitável |
|---|---|---|
| Remover um campo | Consumidores leem undefined imediatamente |
Deprecie, observe e depois remova após a janela |
| Mudar o tipo de um campo | "1" vira 1; clientes fortemente tipados falham de vez e os fracamente tipados frequentemente falham na validação |
Adicione um novo nome de campo e grave em duplicidade durante a transição |
| Adicionar um campo obrigatório | Chamadores antigos não o enviam, então a validação sempre falha | Forneça um padrão, ou torne-o obrigatório apenas na nova versão |
| Mudar o conjunto de valores de enum | Statements switch do consumidor não têm ramo de fallback |
Ensine os clientes a aceitar valores desconhecidos primeiro e depois amplie o enum |
| Mudar o significado de um campo | Os tipos continuam válidos enquanto o comportamento muda — o caso mais difícil de depurar | Trate como um novo campo e congele a semântica antiga |
| Mudar a profundidade de aninhamento | Todo caminho de acesso precisa mudar | Mantenha a forma antiga e adicione campos planos ou um parâmetro expand |
Mudar o significado é a linha mais perigosa: a validação de schema continua verde, o monitoramento não relata erros, e só as métricas de negócio derivam. Qualquer mudança de significado deve ser publicada como um novo campo.
3. Onde a versão vive: documento, URL, tipo de mídia
Os três posicionamentos comuns servem a propósitos diferentes:
| Local | Exemplo | Bom para | Limitação |
|---|---|---|---|
| Campo no documento | "schemaVersion": 2 |
Bancos de dados, filas de mensagens, backups offline | A camada de transporte não consegue rotear por ele |
| Caminho da URL | /v2/orders/8842 |
APIs públicas | Acopla endereços de recursos a versões e torna a migração cara |
| Tipo de mídia | application/vnd.acme.order.v2+json |
Serviços internos que precisam de negociação de conteúdo | Chamadores precisam definir Accept corretamente |
Uma divisão prática do trabalho: use negociação de tipo de mídia no fio e sempre persista um campo de versão no documento. Mensagens antigas em uma fila não vão se atualizar porque você mudou uma URL, e arquivos de backup também não. Só um schemaVersion dentro do documento dá fundamento a replay, preenchimento retroativo e auditorias.
GET /orders/ord-8842 HTTP/1.1
Host: api.bbdrug.com
Accept: application/vnd.acme.order.v2+json
Se você versionar a URL, ao menos mantenha o caminho antigo retornando a forma antiga durante toda a janela de depreciação, em vez de apontá-lo silenciosamente para a nova implementação. Para decisões mais amplas de contrato de API, veja Pontos-chave para projetar contratos de dados de API JSON.
4. Abrindo uma janela de depreciação
Depreciação não é um único anúncio; é uma janela com início, período de observação e fim. Um ritmo viável:
- Congele: pare de gravar o campo antigo, mas continue lendo-o.
- Anuncie: marque a data de remoção na documentação, no changelog e em um cabeçalho de resposta ao mesmo tempo.
- Observe: conte leituras do campo antigo por chamador até que fique em zero por dois períodos consecutivos.
- Remova: só depois que a janela fecha, as leituras estão em zero e os testes de regressão passam, exclua o campo e seu código de compatibilidade.
O cabeçalho de resposta Sunset (RFC 8594) dá aos clientes uma data de remoção legível por máquina. Deprecation ainda é um draft da IETF: utilizável, mas não presuma que todo cliente o entende. Combine-o com um cabeçalho Link apontando para o guia de migração:
Deprecation: true
Sunset: Wed, 31 Mar 2027 23:59:59 GMT
Link: <https://bbdrug.com/docs/deprecations/order-status>; rel="deprecation"; type="text/html"
O comprimento da janela é convenção de equipe. Um piso comum é um ciclo de release ou 90 dias, o que for maior. APIs públicas devem ter mais, serviços internos podem ter menos, mas nunca menos do que o tempo que todo chamador precisa para atualizar.
5. Encontrando consumidores antes de depreciar
Antes de depreciar, você precisa conseguir responder "quem ainda lê isto?" Caso contrário a janela é autoengano. Ordene as evidências por confiabilidade:
- Logs de gateway: agregue acessos ao campo antigo por
appId,User-Agente versão de cliente. - Traces distribuídos: tagueie o campo antigo na camada de serialização para que um trace mostre diretamente o nome do serviço chamador.
- Registro de consumidores de fila: a lista de grupos de consumidores é mais confiável do que uma busca no código.
- Busca no repositório:
rgcobre apenas o seu próprio código; chamadores externos ainda exigem logs.
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats
Um resultado de busca prova "alguém neste repositório o lê", não "ninguém mais o lê". Exporte os logs do gateway dia a dia e confirme que as leituras do campo antigo permaneceram em zero antes de remover qualquer coisa.
6. Migração de dados: expandir, preencher, contrair
O Parallel Change de Martin Fowler descreve três etapas: expandir (campos antigo e novo coexistem e as duas versões podem ser lidas), preencher (mover registros históricos para a nova forma) e contrair (remover o campo antigo e os ramos de compatibilidade). Não inverta a ordem, ou você ficará sem caminho de rollback.
// Esqueleto de migração: cursor por chave primária, gravações condicionais para idempotência
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 };
}
Detalhes facilmente esquecidos:
- Idempotência: inclua o número de versão antigo no
filter, para que reexecutar nunca reescreva um registro já migrado. - Lotes e checkpoints: avance por cursor de
_id, armazene um checkpoint e retome a partir dele após uma falha. - Ensaio: rode o script contra um snapshot de produção e compare contagens de registros e checksums antes e depois.
- Rollback: mantenha o campo antigo até o novo campo passar na validação; troque caminhos de leitura com uma flag e nunca faça rollback apenas editando
schemaVersion. - Validação: rode restrições de negócio após a migração (valores não negativos, transições de estado legais), não apenas uma checagem de parse JSON.
Dados de configuração seguem o mesmo processo; veja Gerenciando JSON para configuração, fixtures e dados locais sobre como organizar esses arquivos.
7. Testes de contrato: coloque a compatibilidade no CI
A compatibilidade não pode depender da memória da revisão de código; ela precisa de testes. Duas camadas bastam:
- Testes de forma: valide respostas com JSON Schema, mas não defina
additionalProperties: falseem schemas usados por consumidores. - Testes de subconjunto do consumidor: extraia os campos em que o consumidor antigo realmente se apoia em um sub-schema e assegure que as respostas novas ainda o satisfazem.
// Teste de contrato: respostas novas ainda precisam satisfazer o subconjunto do consumidor antigo
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));
});
O sub-schema declara apenas os campos e tipos que o consumidor antigo lê e deixa todo campo novo passar. Isso pega campos removidos e mudanças de tipo sem falsos alarmes em adições. Mantenha os dados de caso em um diretório de fixtures, ordenados e comparáveis por diff; para conflitos de merge nesses arquivos, veja Conflitos de Git em arquivos JSON: union grava JSON quebrado, mescle por chave.
8. Lançamento canário: escale por dimensão, pare por métricas
Problemas de compatibilidade raramente aparecem em testes unitários; eles aparecem em um canário. Amplie a exposição do estreito ao amplo:
| Dimensão | Prática | O que valida |
|---|---|---|
| Allowlist de tenant | Habilite o novo caminho de leitura primeiro para clientes internos ou amigáveis | Diferenças reais de forma dos dados |
| Versão de cliente | Habilite apenas para clientes já atualizados para a nova forma | Clientes antigos e novos rodando lado a lado |
| Percentual de tráfego | 5% → 25% → 100%, observando um ciclo de negócio completo por etapa | Chamadas de cauda longa e comportamento de cache |
Acompanhe pelo menos três métricas: taxa de erro da API, taxa de falha de validação de contrato e a proporção de leitura entre campos antigos e novos. Defina condições automáticas de parada, como 5xx acima de 1% ou um repique repentino nas leituras do campo antigo, e reverta a flag. Mantenha as gravações duplas durante o canário para que o rollback só exija trocar o caminho de leitura, não reparar dados.
9. Uma lista de verificação de compatibilidade antes de publicar
- [ ] Campos novos são opcionais, semanticamente independentes e não reutilizam nomes antigos.
- [ ] Consumidores tratam campos desconhecidos e valores de enum desconhecidos com um fallback.
- [ ] O campo de versão é persistido com o documento e é negociável no fio.
- [ ] A janela de depreciação tem anúncio, cabeçalho
Sunsete observação. - [ ] Leituras do campo antigo podem ser rastreadas até chamadores específicos por logs ou traces.
- [ ] O script de migração é idempotente, em lotes, retomável e tem caminho de rollback.
- [ ] Os testes de contrato cobrem o subconjunto do consumidor legado e rodam no CI.
- [ ] O canário tem dimensões de exposição e métricas de parada explícitas.
Perguntas frequentes
Adicionar um campo opcional é sempre retrocompatível?
Não. É compatível apenas quando os consumidores ignoram campos desconhecidos e evitam validação estrita de schema. Um consumidor usando additionalProperties: false ou lendo por posição ainda falhará. Verifique sua tolerância com um teste de contrato antes de publicar.
O schemaVersion deve ficar na URL ou no corpo da resposta?
O fio pode negociar versões por tipos de mídia ou caminhos de URL, mas registros armazenados precisam carregar schemaVersion dentro do documento. Filas, backups offline e auditorias nunca passam por uma URL, então só uma versão dentro do documento consegue sustentar replay e preenchimento retroativo.
Um campo depreciado pode ser excluído imediatamente?
Não. Pare de gravar nele, anuncie a data de remoção e conte leituras por chamador. Exclua apenas depois que a janela fechar e as leituras permanecerem em zero. Antes da exclusão, rode uma regressão completa para confirmar que nenhum cron job oculto ou relatório ainda o lê.
E se um script de migração falhar na metade?
Projete-o como um job idempotente: atualizações condicionais, um cursor de chave primária e checkpoints. Retome do checkpoint após uma falha, e execuções repetidas não devem gravar duas vezes. Para rollback, mantenha o campo antigo e um snapshot; não apenas reverta schemaVersion.
Como devo sequenciar uma migração de dados?
Siga o Parallel Change: expanda primeiro para que campos antigos e novos coexistam e as duas versões possam ser lidas, preencha os registros históricos na nova forma e então contraia removendo o campo antigo e os ramos de compatibilidade. Não inverta a ordem, ou você perde o caminho de rollback, e torne o script de migração idempotente, em lotes e retomável.