Reutilizando JSON Schema: $ref, $defs e composição com allOf/oneOf
Resumo: `$ref` resolve reutilização, `allOf` e `oneOf` resolvem composição, e empilhar os dois é onde as coisas quebram. Cobre `$id`, `$defs`, condicionais, recursão, empacotamento e depuração.

Resumo da ópera: extraia estruturas compartilhadas em $defs com $ref primeiro e depois recorra a allOf/oneOf; quando os ramos puderem corresponder ao mesmo tempo, use anyOf ou uma tag const, e reserve oneOf para casos que realmente exigem exatamente uma correspondência.
Copiar e colar economiza dez minutos e lhe custa uma bifurcação silenciosa três meses depois, quando ninguém lembra qual cópia está correta.
1. Três camadas de reutilização — escolha uma antes de dividir arquivos
O primeiro instinto, quando os campos começam a se multiplicar, é enfiar toda restrição em um único arquivo. É o jeito mais rápido de entregar, mas depois de algumas centenas de linhas toda mudança de campo precisa ser espelhada em três lugares. Erre um e você terá "passa localmente, retorna 500 em produção". Reutilização significa escrever um fato uma vez e referenciá-lo em todo o resto.
O JSON Schema oferece três camadas, cada uma com um escopo diferente:
| Camada | Portador | Escopo | Uso típico |
|---|---|---|---|
| Subestrutura | $defs + $ref |
Um arquivo de schema | Endereço, dinheiro, paginação, formas de erro |
| Arquivo | Schema autônomo + $id |
Vários arquivos em um serviço | Entidades de usuário, pedido, produto |
| Biblioteca de contrato | Repositório de schemas + artefato empacotado | Múltiplos serviços, múltiplas linguagens | Componentes OpenAPI, contratos publicados |
Para a maioria dos projetos a primeira camada basta. Dividir em arquivos só compensa quando dois serviços genuinamente compartilham o mesmo contrato, ou quando o schema é um artefato publicado consumido por outra linguagem. Dividir cedo demais torna a resolução de $id e caminhos relativos mais difíceis de depurar, e esse custo costuma exceder o que você economizou evitando duplicação.
Uma regra simples: se uma estrutura aparece em dois lugares, uma cópia é tolerável; quando aparece em um terceiro, promova-a para $defs; só quando outra linguagem ou repositório precisa lê-la ela merece um arquivo autônomo com seu próprio $id.
2. $id estabelece identidade, $ref executa o salto
$id não é um comentário. Ele declara a URI base do schema. Um valor $ref é uma referência de URI: ele é resolvido contra a URI base atual em uma URI absoluta, e depois o fragmento após # localiza um nó. Se a URI base está errada, toda referência relativa aponta para outro lugar, embora o erro normalmente só diga "can't resolve reference" sem nomear o host errado.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/order.json",
"$defs": {
"address": {
"$anchor": "address",
"type": "object",
"required": ["city"],
"properties": {
"city": { "type": "string", "minLength": 1 },
"detail": { "type": "string" }
}
}
},
"type": "object",
"required": ["shipTo"],
"properties": {
"shipTo": { "$ref": "#/$defs/address" },
"billTo": { "$ref": "#address" }
}
}
shipTo usa um JSON Pointer enquanto billTo usa uma âncora; ambos caem no mesmo nó. A tabela abaixo mapeia cada forma para sua resolução:
| Forma | Aponta para | Draft | Observações |
|---|---|---|---|
# |
Raiz do schema atual | Todos | Comum para recursão |
#/$defs/address |
Nó sob $defs |
2019-09 e posteriores | O mais claro; preferido |
#address |
Âncora nomeada | $anchor no 2020-12 |
O draft-07 escrevia $id: "#address" |
common.json |
Arquivo relativo à URI base | Todos | Precisa ser registrado ou empacotado |
https://example.com/x.json |
URI absoluta | Todos | Evite buscar em tempo de execução em produção |
Uma armadilha sutil: se um sub-schema dentro de $defs declara seu próprio $id, ele se torna a nova URI base para tudo abaixo dele. Cole um schema copiado de outro arquivo e o $id remanescente redirecionará suas referências relativas para o host original — e o validador só percebe quando realmente tenta resolvê-las.
3. $defs é um contêiner, não um local de validação
$defs é um mapa comum. Nada dentro dele entra em vigor até que um $ref aponte para lá. Uma lista required no topo de $defs sem nenhuma referência recebida não valida absolutamente nada, por mais estrita que pareça. Isso é a especificação funcionando como pretendido, não um bug do validador.
{
"$defs": {
"money": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]{2}$",
"description": "Uma string monetária com duas casas decimais que evita desvio de ponto flutuante"
},
"page": { "type": "integer", "minimum": 1 }
},
"properties": {
"total": { "$ref": "#/$defs/money" },
"page": { "$ref": "#/$defs/page" }
}
}
Algumas convenções vale fixar na equipe:
- Chaves dentro de
$defsnão carregam semântica.address,Addresseaddrsão todas legais, então acorde um estilo. - Antes do 2019-09 a palavra-chave era
definitions. Ambas podem ser alvo de$ref, mas o modo estrito pode relatardefinitionscomo palavra-chave desconhecida. $defspode conter qualquer schema, incluindo um fragmentoallOfou um sub-schema independente com seu próprio$id.- Não trate
$defscomo herança de template. Ele só armazena definições; o significado ainda vem do contexto que referencia.
4. allOf, anyOf e oneOf não são intercambiáveis
Os três envolvem múltiplos ramos, mas suas regras de aceitação diferem. Quando os dados correspondem a dois ramos, allOf e anyOf passam enquanto oneOf falha — o motivo mais comum de as pessoas concluírem "o schema está quebrado".
| Palavra-chave | Correspondências mínimas | Correspondências máximas | Dados correspondendo a dois ramos | Qualidade dos erros | Uso típico |
|---|---|---|---|---|---|
allOf |
Todas | Ilimitadas | Passa | Boa, uma restrição por vez | Combinar fragmentos de restrição |
anyOf |
1 | Ilimitadas | Passa | Ruim, ramos se acumulam | "E-mail ou telefone, qualquer um" |
oneOf |
1 | 1 | Falha | Ruim | Unions discriminadas |
not |
0 | 0 | Falha | A pior | Excluir alguns valores; raramente usado |
{
"allOf": [
{ "$ref": "#/$defs/auditable" },
{ "$ref": "#/$defs/softDeletable" },
{ "required": ["email"] }
],
"unevaluatedProperties": false
}
allOf não mescla definições de objeto; ele só exige que todo ramo valha. Cada ramo é avaliado independentemente, então um additionalProperties: false dentro de um ramo conhece apenas as chaves que aquele ramo declarou e sinalizará campos declarados por outro ramo como extras. No 2020-12 a correção é colocar a política de propriedade desconhecida no nível superior e usar unevaluatedProperties: false, que é avaliado uma vez após todos os ramos.
Use anyOf para "várias formas legais", não para "várias formas mutuamente exclusivas". Se dois ramos podem corresponder ao mesmo tempo, oneOf produz o famoso e inútil must match exactly one schema in oneOf.
5. if/then/else e discriminador: torne as condições decidíveis
Restrições condicionais tendem a produzir erros melhores que oneOf porque o caminho de decisão é explícito: if passa, then se aplica; caso contrário else se aplica.
{
"type": "object",
"required": ["kind"],
"properties": { "kind": { "enum": ["text", "image"] } },
"if": {
"properties": { "kind": { "const": "image" } },
"required": ["kind"]
},
"then": { "required": ["url", "width", "height"] },
"else": { "required": ["content"] }
}
Três detalhes determinam se ele se comporta como pretendido:
- Uma entrada em
propertiesdentro deifnão falha quando a chave está ausente. Combine-a comrequiredpara expressar "kind existe e é igual a image". ifem si nunca produz erro de validação. Falhá-lo simplesmente encaminha paraelse, então ele não consegue bloquear dados ruins sozinho.- Um
thenouelseisolado é inerte. Eles só se aplicam ao lado de umif.
Conforme os ramos de union crescem, a cadeia condicional vira um aninhamento impossível de manter. Um padrão mais robusto é travar um campo de tag dentro de cada ramo, para que os ramos sejam mutuamente exclusivos por construção:
{
"oneOf": [
{
"properties": { "kind": { "const": "card" } },
"required": ["kind", "cardNumber"]
},
{
"properties": { "kind": { "const": "account" } },
"required": ["kind", "accountId"]
}
]
}
discriminator é uma extensão do OpenAPI 3.x, não uma palavra-chave do JSON Schema. O Ajv a suporta quando a opção discriminator: true é habilitada, mas seu mapeamento vive em mapping e não é portável entre cadeias de ferramentas. Se o schema precisa funcionar em vários validadores, prefira o padrão de tag const, que não depende de extensão nenhuma.
6. Referências recursivas: árvores, listas ligadas e arquivos que se citam
Estruturas recursivas não precisam de sintaxe especial — um $ref de volta a si mesmo basta. Validadores não expandem referências infinitamente em tempo de compilação; eles recorrem de acordo com a profundidade real dos dados, então algumas dezenas de linhas descrevem uma árvore de qualquer altura.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/tree-node.json",
"$defs": {
"node": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"children": {
"type": "array",
"items": { "$ref": "#/$defs/node" }
}
}
}
},
"$ref": "#/$defs/node"
}
Três fronteiras vale resolver de antemão:
- Profundidade dos dados: o schema não impõe limite, mas validação e serialização consomem pilha proporcional à profundidade. APIs públicas devem acrescentar um limite de negócio, digamos oito níveis, ou um payload maliciosamente profundo pode estourar a pilha.
- Referências mútuas: A referencia B e B referencia A é legal, mas ambos os schemas precisam ser registrados primeiro; caso contrário a ordem de resolução decide o desfecho.
- Ramos anuláveis: se um campo recursivo pode ser
null, escreva"type": ["object", "null"]ou envolva-o emoneOf.$refnão herda anulabilidade.
7. Referências entre arquivos e empacotamento: nunca busque schemas em produção
Referências entre arquivos frequentemente "simplesmente funcionam" em desenvolvimento porque o validador busca o alvo por $id pela rede. Mude a política de rede na hora do deploy e toda a cadeia de validação quebra. A regra de produção é simples: todo schema referenciado precisa estar registrado no processo ou empacotado antecipadamente.
import Ajv2020 from 'ajv/dist/2020';
import addressSchema from './address.json' with { type: 'json' };
import orderSchema from './order.json' with { type: 'json' };
const ajv = new Ajv2020({ allErrors: true, strict: true });
// Registre primeiro o arquivo referenciado; a chave precisa ser igual ao seu $id, ou refs relativas não resolvem
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');
// Compile o schema de entrada uma vez, no escopo do módulo
export const validateOrder = ajv.compile(orderSchema);
Se order.json tem $id: "https://bbdrug.com/schemas/order.json", seu "$ref": "address.json" resolve para https://bbdrug.com/schemas/address.json, que corresponde à chave registrada. Um único caractere diferente resulta em can't resolve reference.
As três abordagens práticas se comparam assim:
| Abordagem | Como | Ponto forte | Custo |
|---|---|---|---|
| Registrar em tempo de execução | addSchema por arquivo |
Sem etapa de build, os arquivos permanecem independentes | Ordem de registro e chaves são manuais |
| Empacotar em tempo de build | Mesclar com um ref parser em um arquivo | Zero dependências de tempo de execução | Artefato maior, diffs barulhentos |
| Carregamento assíncrono | loadSchema + compileAsync |
Lida com schemas que mudam com frequência | Preocupações de rede e cache; evite em caminhos críticos |
Para projetos de frontend, empacotar em tempo de build é mais seguro. No servidor, o registro basta, desde que um teste prove que toda referência resolve. De qualquer forma, rode compile no CI para que erros de referência apareçam antes do merge em vez de na primeira requisição de produção.
8. Ordem de depuração: resolver, compilar, depois inspecionar ramos
Quase todo problema de $ref pode ser localizado em três etapas, e a ordem importa:
| Sintoma | Causa provável | Correção |
|---|---|---|
can't resolve reference ... from id ... |
Erro de digitação em $id, base relativa errada, alvo não registrado |
Imprima o $id e a URI absoluta resolvida e depois compare com as chaves registradas |
schema with key or id already exists |
O mesmo $id registrado duas vezes, ou compile chamado repetidamente |
Mova o registro para a inicialização do módulo e rode uma vez |
strict mode: unknown keyword: "definitions" |
Mistura de sintaxe draft-07 | Renomeie para $defs; não desabilite o modo estrito |
Os dados correspondem a dois ramos oneOf |
Restrições de ramo sobrepostas, sem campo de tag | Acrescente const a cada ramo, ou mude para anyOf |
Restrições dentro de $defs não têm efeito |
Nada os referencia | Verifique o caminho da referência; $defs não valida nada por si só |
Ao depurar, leia schemaPath antes de message: ele nomeia o ramo e a palavra-chave que falharam e é muito mais confiável que a prosa em inglês. Faça cache do validador compilado no escopo do módulo em vez de recompilar por requisição. Para toda mudança de schema, cubra tanto um caso que passa quanto um que falha — testar apenas o caminho feliz não testa nada. Para seleção de validador e design de mensagens de erro, veja JSON Schema: do básico à produção; para conectar um schema à geração de tipos, veja Sincronização bidirecional entre Schema e tipos TypeScript.
9. Perguntas frequentes
$ref pode ficar ao lado de type ou description?
O 2020-12 permite $ref ao lado de palavras-chave irmãs, e essas irmãs são avaliadas normalmente. No draft-07 as irmãs são ignoradas e apenas $ref se aplica. Essa é a maior diferença comportamental quando o mesmo schema é usado entre drafts, então confirme qual draft seu validador suporta antes de migrar.
Devo usar $defs ou definitions?
Use $defs para trabalho novo; ele chegou com o 2019-09. definitions não é uma palavra-chave no 2020-12. Um pointer como $ref: "#/definitions/foo" ainda pode resolver porque é JSON Pointer puro, mas o modo estrito relatará uma palavra-chave desconhecida. Misturar os dois estilos deixa uma mina que só explode no CI.
Por que oneOf rejeita dados que parecem válidos?
Porque os dados satisfazem dois ramos ao mesmo tempo. oneOf exige exatamente um, e um ramo que restringe apenas parte do objeto é satisfeito mesmo quando os campos do outro ramo estão ausentes. Acrescente uma tag const a cada ramo para torná-los mutuamente exclusivos.
Referências recursivas farão loop para sempre?
Não. Validadores não expandem referências em tempo de compilação; eles recorrem sobre os dados reais. Os riscos reais são dados profundos o bastante para estourar a pilha e ordem de resolução errada entre arquivos mutuamente referenciados. Um limite de profundidade em nível de negócio em campos recursivos é mais eficaz que qualquer truque de schema.
A produção pode depender de $ref remoto?
Não. Referências remotas amarram a disponibilidade da validação à rede: mudanças de domínio, certificados expirados e isolamento de rede interna todos quebram a cadeia. Empacote os schemas referenciados no artefato ou registre-os na inicialização se quiser comportamento previsível.
Como decido entre $defs e arquivos separados?
Uma estrutura que aparece em dois lugares pode ser copiada uma vez; quando aparece em um terceiro lugar, promova-a para $defs. Só quando outra linguagem ou outro repositório precisa lê-la ela merece um arquivo autônomo com seu próprio $id.
Por que restrições dentro de $defs não têm efeito?
$defs é apenas um contêiner, e nós para os quais nenhum $ref aponta nunca participam da validação. Uma lista required no topo de $defs sem referência recebida ainda deixa os dados passarem — isso é a especificação, não um bug do validador.
Devo colocar additionalProperties: false dentro de ramos allOf?
Não. Cada ramo é avaliado independentemente, então um ramo trata campos declarados por outro ramo como extras. A abordagem do 2020-12 move a política de campo desconhecido para o nível superior e usa unevaluatedProperties: false, avaliado uma vez após todos os ramos.