Formatos de resposta JSON que sobrevivem cinco anos
Quase toda migração dolorosa de API remonta a uma decisão de formato tomada numa tarde e congelada pela primeira integração.
Cada afirmação desta página foi medida ou tem fonte. Quando não é nem uma coisa nem outra, a página diz isso.
Eis uma resposta que saiu na v1 de alguém e continua saindo hoje:
[
{ "id": 8102, "name": "Ada" },
{ "id": 8103, "name": "Grace" }
]
Três anos depois a coleção está grande o bastante para precisar de paginação, e não há onde colocar um cursor. O nível de topo é um array. Envolvê-lo muda o tipo que todo cliente já faz parsing, então o time lança /v2/users e mantém dois caminhos de código para sempre. Nada naquele array estava errado quando foi escrito. Ele só não tinha espaço para crescer.
É disso que se trata tudo isto. Design de resposta não é sobre elegância, é sobre quais mudanças continuam baratas.
Envelope ou valor nu
Um envelope é um objeto de topo com a carga sob uma chave:
{
"data": [ { "id": "8102", "name": "Ada" } ],
"nextCursor": "eyJpZCI6ODEwM30",
"hasMore": true
}
O argumento contra é real: é ruído, e todo cliente escreve .data. O argumento a favor é que um objeto é extensível e um array nu não. Você pode acrescentar depois um cursor, um total, um aviso de descontinuação ou um id de rastreio sem mudar o tipo de nada que já esteja lá.
O que eu faço: envelopar coleções, devolver o objeto nu para um recurso único. Um recurso único já é um objeto, então tem o espaço para crescer que um envelope lhe daria. Coleções ganham o envelope porque são elas que uma hora precisam de metadados.
O que você escolher, escolha uma vez. Metade dos seus endpoints envelopada e metade nua é pior que qualquer uma das duas. E não coloque um campo chamado data dentro de um campo chamado data.
Os tipos de campo dos quais não dá para voltar atrás
IDs são strings. Sempre, inclusive enquanto ainda são inteiros pequenos. Um número JSON no JavaScript é um double IEEE 754, então qualquer identificador acima de 9007199254740991 é arredondado em silêncio na chegada e você agora está olhando para outro registro. O Twitter bateu nisso quando migrou para IDs Snowflake de 64 bits em 2010 e passou a enviar id_str ao lado de id, e o padrão pegou. A mecânica está em por que os seus IDs em JSON mudam de valor. O ponto de design é mais estreito: um identificador não é uma quantidade. Você nunca soma nele, nem ordena aritmeticamente, nem tira média, então um tipo numérico não compra nada e te custa no dia em que você mudar para UUIDs.
Dinheiro é um inteiro em unidades menores, ou uma string decimal. Nunca um float.
{ "amountMinor": 1005, "currency": "GBP" }
{ "amount": "10.05", "currency": "GBP" }
1.005 não é exatamente representável como double, então no JavaScript 1.005 * 100 é 100.49999999999999, que arredonda para 100 em vez de 101. Escolha uma representação, carregue a moeda ao lado, e nunca deixe um price: 10.05 nu entrar no schema, porque tirar depois significa auditar todo consumidor que faz aritmética com ele.
Datas são strings RFC 3339 com deslocamento explícito. "2026-09-05T14:30:00Z". Não um timestamp Unix, não "05/09/2026", e acima de tudo não uma hora local sem deslocamento, porque isso faz parsing sem reclamar e está errado por horas. JSON não tem tipo data, então essa convenção só existe se a revisão de código a fizer valer. Formatos de data e hora em JSON cobre o resto.
null, ausente e vazio
Quatro formas, quatro significados:
| Forma | Significado |
|---|---|
"middleName": "Jane" |
Valor conhecido |
"middleName": null |
Sabidamente sem valor |
| chave ausente | Não sabido, não carregado ou não permitido |
"tags": [] |
Sabidamente zero tags |
O erro não é escolher a convenção errada, é usar as quatro de forma inconsistente, de modo que um cliente não consegue distinguir “este usuário não tem nome do meio” de “você pediu uma projeção parcial”. Decida por campo e segure a linha.
Duas armadilhas. O JSON.stringify descarta chaves cujo valor é undefined mas mantém null, então um produtor em JavaScript oscila entre ausente e null conforme uma variável tenha sido atribuída. E o required do JSON Schema afirma que uma chave está presente, não que ela é não nula: {"name": null} satisfaz required: ["name"]. Se você quer dizer não nulo, coloque isso no tipo.
{
"type": "object",
"required": ["name", "middleName"],
"properties": {
"name": { "type": "string" },
"middleName": { "type": ["string", "null"] }
}
}
Gere o primeiro rascunho a partir de um payload real com o gerador de schema, depois conserte a nulabilidade à mão, porque um gerador só enxerga os valores que por acaso estavam na sua amostra.
Nomes
Escolha camelCase ou snake_case, aplique a toda chave de todo endpoint, e pare de ter essa conversa. Caixa misturada num documento só é o sinal mais claro de que dois times escreveram duas metades e nenhum leu o outro, e quebra o truque barato do cliente de mapear chaves mecanicamente em campos de struct. created_at é melhor que ts. Uma chave que precisa de comentário precisa de um nome melhor.
Erros
Um corpo de erro precisa de três coisas separadas, e a maioria envia uma:
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient funds",
"status": 402,
"detail": "Balance is 320 minor units, transfer requires 1005.",
"code": "INSUFFICIENT_FUNDS",
"pointer": "/transfer/amountMinor"
}
Um código de máquina estável no qual o cliente ramifica, e que você se compromete a nunca mudar. Uma mensagem humana que você fica livre para reescrever ou traduzir, e com a qual nenhum cliente deveria casar. E um ponteiro para o campo culpado, idealmente um JSON Pointer da RFC 6901 para que ele resolva mecanicamente contra o corpo da requisição.
A RFC 9457, Problem Details for HTTP APIs, padroniza type, title, status, detail e instance, e permite explicitamente membros de extensão, então você pode adotá-la e ainda carregar o seu próprio code. Ela tornou obsoleta a RFC 7807, nome pelo qual a maioria das implementações existentes a conhece. Usá-la te dá um formato que a ferramentaria dos outros já entende, coisa que {"error": "algo deu errado"} nunca vai dar. Para falhas de validação, devolva todas em vez da primeira.
Cursores ganham de offsets
Paginação por offset fica silenciosamente com perdas no momento em que há escritas concorrentes. A página um devolve as linhas 1 a 50. Uma linha é inserida perto do topo. A página dois, offset=50, agora começa no que era a linha 50, então o consumidor vê aquele registro duas vezes. Exclusões fazem o inverso e pulam registros de vez. Nada dá erro; aparece semanas depois como divergência de conciliação.
Um cursor codifica uma posição numa ordenação estável, normalmente a chave de ordenação mais um id de desempate, então inserções acima dele são irrelevantes. Documente o cursor como opaco para poder mudar a codificação depois, e devolva um hasMore explícito em vez de fazer os clientes inferirem o fim a partir de uma página curta. Pule o totalCount a menos que alguém realmente precise e você esteja disposto a pagar a segunda consulta.
Mudança aditiva é a única mudança grátis
O contrato que torna a evolução possível vive do lado do cliente: campos desconhecidos devem ser ignorados. Se isso vale, acrescentar um campo não quebra nada e você pode lançar continuamente. Se um consumidor valida estritamente, ou gera tipos com additionalProperties: false, toda adição quebra alguém e você fica na v1 para sempre. Diga isso no primeiro parágrafo da sua documentação.
Todo o resto é uma versão: remover um campo, renomear um, mudar o tipo dele, mudar o que um valor significa, apertar o que você aceita, ou tornar não anulável um campo anulável. Passar o payload de exemplo do lançamento anterior e o de agora por um diff de JSON pega a mudança de tipo que ninguém quis fazer.
Arrays heterogêneos custam ao consumidor mais do que economizam para você
{ "items": [
{ "kind": "comment", "body": "..." },
{ "kind": "reaction", "emoji": "..." },
{ "id": 7, "legacy": true }
] }
Todo consumidor agora escreve um despacho, e todo consumidor tipado estaticamente escreve uma união etiquetada à mão. Se você precisa misturar formatos, discrimine-os: um kind obrigatório com um conjunto fechado e documentado de valores, presente em todo membro. Aí a união vira mecânica. A versão imperdoável é o terceiro elemento, em que o formato varia sem etiqueta e os clientes farejam chaves. O mesmo para um campo que às vezes é string e às vezes é objeto: te poupa um salto de versão e custa a cada cliente um type guard para sempre.
Quando a resposta fica grande
Todo motor tem um teto rígido de comprimento de string, e ele é mais baixo do que as pessoas esperam: no V8 de 64 bits (Chrome e Node) são 536.870.888 caracteres, então uma resposta acima de aproximadamente meio gigabyte não pode nem ser segurada como string, quanto mais parseada. Outros motores ficam mais alto, mas todos têm teto, e a árvore de objetos parseada custa várias vezes o que o texto custou. Muito antes de qualquer disso, um parsing de vários segundos bloqueia a thread principal.
Três saídas, em ordem de quanto perturbam a API. Paginar mais fino para que nenhuma resposta seja grande. Transmitir registros delimitados por linha para que o consumidor trabalhe conforme recebe em vez de esperar uma chave de fechamento (NDJSON e JSON Lines). Ou tirar a exportação em massa da API síncrona de vez: devolva um id de job e uma URL assinada para o arquivo pronto. Arquivos JSON grandes cobre o lado do consumidor.
A lista de conferência
- Envelope coleções, devolva objetos nus para recursos únicos, e seja consistente.
- IDs são strings. Dinheiro é unidade menor ou string decimal. Datas são RFC 3339 com deslocamento.
- Defina o que null, ausente e vazio significam, campo a campo.
- Uma convenção de caixa em todos os endpoints.
- Erros carregam um código estável, uma mensagem mutável e um ponteiro de campo. Considere a RFC 9457.
- Paginação por cursor, cursores opacos, um
hasMoreexplícito. - Diga aos clientes para ignorar campos desconhecidos, e então mantenha toda outra mudança atrás de uma versão.
- Discrimine todo array heterogêneo com um
kindobrigatório.
Nada disso é caro no dia um. Tudo isso é caro no dia mil, que é a única razão pela qual vale discutir agora.