Pontos-chave para projetar contratos de dados de API JSON
Resumo: Seis coisas são muito difíceis de mudar depois de publicadas: envelopes de resposta, formatos de erro, formato de paginação, representação de IDs e tempo, a semântica de null versus ausente e a evolução de versão.

Resumo da ópera: trave o envelope de resposta, o formato de erro, a representação de ID e tempo e a semântica de null versus ausente no contrato antes de discutir detalhes de paginação e nomeação; sempre envolva o nível superior em um envelope data para que paginação e IDs de rastreio possam ser adicionados depois sem quebrar chamadores existentes.
Não há respostas padrão em projeto de API, mas há algumas decisões "difíceis de retomar depois de escolhidas" que é melhor pensar antes de escrever a primeira linha de código.
1. Envolver a resposta ou não
Três estilos:
// Estilo A: resposta crua
{ "id": 1, "name": "张三" }
// Estilo B: envelope de dados
{ "data": { "id": 1, "name": "张三" } }
// Estilo C: dados + metadados
{ "data": { "id": 1, "name": "张三" }, "meta": { "requestId": "a1b2" } }
| Estilo | Prós | Contras |
|---|---|---|
| Resposta crua | Mais simples, uma camada a menos para o frontend | Não consegue anexar metadados; um array de nível superior carrega riscos de JSON hijacking e de evolução |
| Envelope de dados | Fácil adicionar meta / errors / links |
Um nível de aninhamento a mais, o frontend precisa escrever res.data.data |
| Dados + metadados | Melhor observabilidade | Exige acordo estrito sobre quais campos pertencem a meta |
Recomendação: use um envelope data uniformemente para listas e detalhes, e nunca faça o nível superior ser um array. Assim, adicionar paginação ou IDs de rastreio depois nunca quebra o contrato.
2. Formato de erro: copie a RFC 9457
Inventar sua própria estrutura de erro é uma enorme fonte de custo de colaboração. A RFC 9457 (que substitui a RFC 7807) define application/problem+json:
{
"type": "https://bbdrug.com/problems/insufficient-balance",
"title": "余额不足",
"status": 422,
"detail": "账户余额 12.00 元,本次扣款需要 30.00 元",
"instance": "/accounts/A1001/payments/p-889",
"balance": "12.00",
"required": "30.00"
}
| Campo | Significado | Obrigatório |
|---|---|---|
type |
URI estável do tipo de problema | Recomendado |
title |
Resumo curto legível por humanos | Recomendado |
status |
Código de status HTTP | Recomendado |
detail |
Explicação específica do que aconteceu desta vez | Opcional |
instance |
A instância de recurso que teve o problema | Opcional |
| Campos de extensão | Campos de negócio arbitrários | Opcional |
Campos de extensão (como balance / required acima) são permitidos pela especificação, e o frontend pode usá-los para mensagens precisas em vez de analisar o texto de detail.
3. Paginação: cursor vence offset
// Paginação por offset
{ "data": [], "page": { "offset": 40, "limit": 20, "total": 137 } }
// Paginação por cursor
{ "data": [], "page": { "nextCursor": "eyJpZCI6MTAwfQ", "hasMore": true } }
| Dimensão | Paginação por offset | Paginação por cursor |
|---|---|---|
| Dificuldade de implementação | Baixa (LIMIT/OFFSET) |
Média (exige uma chave de ordenação estável) |
| Desempenho em páginas profundas | Ruim (quanto maior o offset, mais lento) | Bom |
| Quando os dados mudam | Duplicatas ou itens ausentes | Estável |
| Pular para uma página | Possível | Não possível |
| Precisa de uma contagem total | Exige uma contagem extra | Não exigida |
Recomendação: use paginação por offset em tabelas administrativas (elas precisam de saltos de página e totais), e paginação por cursor em feeds infinitos voltados ao usuário.
4. IDs e tempo
4.1 Sempre use strings para IDs
O raciocínio foi coberto no artigo sobre tipos: no lado JavaScript, valores acima de 2^53 perdem precisão silenciosamente.
Mesmo que o ID atual seja um inteiro autoincremental pequeno, usar strings deixa espaço para mudar para IDs snowflake depois.
{ "id": "1000000000000000001" }
4.2 Use strings RFC 3339 para tempo
{
"createdAt": "2026-10-30T10:41:45+08:00",
"updatedAt": "2026-10-30T02:41:45Z"
}
- Não use números de timestamp em milissegundos: eles são difíceis de ler, e a unidade (segundos/milissegundos) é frequentemente confundida;
- Especifique o fuso horário explicitamente: ou com
Z, ou com um offset; nunca use "hora local sem fuso horário"; - Nomeie os campos uniformemente como
xxxAtpara que fique óbvio à primeira vista que é um ponto no tempo.
5. null e ausente
Você precisa escolher uma e escrever na documentação:
| Abordagem | Expressar "sem valor" | Expressar "não modificar" |
|---|---|---|
| Abordagem A | O valor do campo é null |
Omita o campo na requisição |
| Abordagem B | O campo não existe | Omita o campo na requisição |
O problema da abordagem A é que "omitido" e "enviado como null" significam coisas diferentes em um PATCH, o que é fácil de mal interpretar;
o problema da abordagem B é que o JSON.stringify do frontend descarta campos cujo valor é undefined, o que por acaso corresponde à semântica.
Recomendação prática:
- Use a abordagem A para corpos de resposta (
nullclaramente significa sem valor, e o frontend não precisa distinguirundefined); - Use a abordagem B para corpos de requisição (especialmente
PATCH), combinada com Merge Patch ou JSON Patch para tornar a semântica explícita.
6. Nomeação e estrutura de campos
| Decisão | Escolhas comuns | Cuidado |
|---|---|---|
| Estilo de nomeação | camelCase (ecossistema JS) / snake_case (Python, bancos de dados) |
Uniforme em todo o projeto, não misture |
| Valores de enum | Minúsculas com underscores, "pending_review" |
Não use números; são difíceis de ler |
| Campos booleanos | isActive / enabled |
Não use status: 0/1 |
| Campos de array | Substantivos no plural, items |
Retorne [] para arrays vazios, nunca null |
| Profundidade de aninhamento | No máximo 3 níveis | Mais profundo que isso, divida ou ofereça um parâmetro expand |
Retornar [] em vez de null para arrays vazios importa muito: data.items.map(...) no frontend não quebrará por causa de um null.
7. Estratégia de evolução
| Mudança | Quebra compatibilidade | Como lidar |
|---|---|---|
| Adicionar um campo opcional | Não | Publique diretamente |
| Adicionar um campo obrigatório | Sim | Adicione um valor padrão, ou publique por versão |
| Remover um campo | Sim | Marque como obsoleto primeiro, observe os chamadores e depois remova |
| Mudar o tipo de um campo | Sim | Adicione um novo nome de campo e grave em duplicidade durante a transição |
| Mudar o conjunto de valores de enum | Depende | O cliente precisa ter fallback para valores desconhecidos |
| Mudar a semântica de um campo | Sim | Trate como um novo campo |
Três regras de disciplina:
- Clientes precisam ser tolerantes a campos desconhecidos (não se prenda com
additionalProperties: false); - Clientes precisam ter um ramo de fallback para valores de enum desconhecidos;
- Mantenha campos obsoletos por pelo menos uma versão principal, e marque-os com
Deprecationnos cabeçalhos de resposta ou na documentação.
8. Um exemplo completo
{
"data": [
{
"id": "1001",
"name": "JSON 权威指南",
"price": "89.50",
"tags": ["json", "spec"],
"publishedAt": "2026-09-01T00:00:00+08:00",
"author": null
}
],
"page": { "nextCursor": null, "hasMore": false },
"meta": { "requestId": "req-7f3a", "tookMs": 12 }
}
Esta resposta reflete todas as convenções acima: envelope data, IDs como strings, valores monetários como strings, timestamps com fuso horário,
null significando ausente, array vazio, paginação por cursor e meta para informações de observabilidade.
9. Lista de verificação
- [ ] O nível superior não é um array; use um envelope
datauniformemente; - [ ] As respostas de erro seguem a RFC 9457 e incluem um
typeestável; - [ ] O formato de paginação corresponde ao cenário de negócio;
- [ ] IDs são strings, e o tempo usa RFC 3339;
- [ ] A semântica de
nulle ausente está escrita na documentação; - [ ] O estilo de nomeação é uniforme em todo o projeto, e os enums usam strings em minúsculas;
- [ ] O cliente tem fallbacks para campos desconhecidos e valores de enum desconhecidos;
- [ ] Mudanças disruptivas passam por versionamento ou por uma transição com gravação dupla.
Perguntas frequentes
Como devo projetar uma API JSON evolutiva?
Envolva listas e detalhes uniformemente em um envelope data e nunca faça o nível superior ser um array, para que paginação e IDs de rastreio possam ser adicionados sem quebrar o contrato. Use IDs como strings e timestamps RFC 3339, e escreva a semântica de null versus ausente na documentação. Mantenha campos novos opcionais e semanticamente independentes, e dê aos clientes fallbacks para campos e valores de enum desconhecidos.
Por que não inventar um formato de erro personalizado?
Inventar sua própria estrutura de erro é uma enorme fonte de custo de colaboração. Copie o application/problem+json da RFC 9457, identifique problemas com um type estável e permita campos de extensão como balance e required, para que o frontend consiga construir mensagens precisas sem analisar o texto de detail.
Todo endpoint deve usar paginação por cursor?
Não. Tabelas administrativas precisam de saltos de página e totais, então a paginação por offset encaixa melhor; feeds infinitos voltados ao usuário devem usar paginação por cursor, que permanece estável em páginas profundas e evita duplicatas ou lacunas quando os dados mudam. Escolha com base em o cenário precisar de saltos de página e totais.