JSON Toolkit

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.

Avançado~10 min de leituraPublicado em 2026-10-31Atualizado em 2026-10-31
Capa de versionamento de contratos JSON, compatibilidade e migração de dados

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 status de "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:

  1. Congele: pare de gravar o campo antigo, mas continue lendo-o.
  2. Anuncie: marque a data de remoção na documentação, no changelog e em um cabeçalho de resposta ao mesmo tempo.
  3. Observe: conte leituras do campo antigo por chamador até que fique em zero por dois períodos consecutivos.
  4. 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-Agent e 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: rg cobre 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:

  1. Testes de forma: valide respostas com JSON Schema, mas não defina additionalProperties: false em schemas usados por consumidores.
  2. 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 Sunset e 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.