Skip to content
jsonforge.app
Voltar ao blog
Guia9 min de leitura

Design de respostas JSON em APIs REST: boas práticas e antipadrões

Os bytes que uma API REST retorna importam tanto quanto as rotas e os códigos de status — todo cliente que um dia chamar sua API é escrito contra o formato do seu JSON, e remodelar esse JSON depois é uma breaking change por menor que pareça internamente. Este guia cobre as decisões de design de resposta que são baratas de acertar no início e caras de corrigir quando já existem clientes.

Escolha um formato de envelope e nunca dele desvie

Um envelope é o wrapper consistente de nível superior que toda resposta usa, independente do endpoint: um conjunto conhecido de chaves como `data`, `error` e `meta` que a camada HTTP de um cliente consegue tratar genericamente, em vez de escrever parsing específico por endpoint. Sem um, todo endpoint que retorna um formato ligeiramente diferente obriga todo cliente a escrever um parser ligeiramente diferente.

Um envelope consistente: respostas de sucesso e de erro compartilham o mesmo formato de nível superior.
json
// Success
{
  "data": { "id": 42, "name": "Ada Lovelace" },
  "meta": { "requestId": "a1b2c3" }
}

// Failure
{
  "data": null,
  "error": { "code": "NOT_FOUND", "message": "User 42 does not exist" },
  "meta": { "requestId": "a1b2c4" }
}

Os nomes das chaves importam menos do que a consistência: o que você escolher, todo endpoint retorna, `data` é sempre onde o payload vive, e um cliente pode escrever uma única função `unwrap(response)` para a API inteira em vez de uma por recurso.

Nunca retorne um array (ou escalar) puro no nível superior

`GET /users` retornando um array JSON puro — `[{...}, {...}]` — parece natural, mas impede para sempre adicionar metadados no nível da resposta sem uma breaking change. No momento em que você precisa de uma contagem total, um cursor de próxima página ou uma mensagem de aviso junto com a lista, é preciso mudar o tipo de nível superior de array para objeto, o que quebra todo cliente que faz `response.map(...)` ou `response.length` diretamente sobre o corpo analisado.

Envolva coleções num campo nomeado desde o primeiro dia — `{ "users": [...] }` ou `{ "data": [...] }` — mesmo quando você tem certeza de que nunca vai precisar de metadados extras. Adicionar uma chave irmã a um objeto é uma mudança compatível com versões anteriores; transformar um array em objeto não é, para nenhum cliente, em nenhuma linguagem.

Null vs. omitido: não são o mesmo sinal

Um campo presente com valor `null` e um campo simplesmente deixado de fora da resposta parecem 'sem valor' à primeira vista, mas significam coisas diferentes, e os clientes precisam conseguir distingui-los. `null` diz: este campo é um conceito real e conhecido para este recurso, e seu valor atual é explicitamente nada (um usuário sem `middleName`). Omitido diz: este campo não se aplica, não foi carregado ou não foi solicitado (uma requisição de campos esparsos que pediu apenas `id` e `name`).

Essa distinção é estruturante especificamente na semântica do PATCH: sob JSON Merge Patch (RFC 7396), enviar um campo como `null` significa apagar esse campo do destino, enquanto simplesmente não incluir o campo no corpo do patch significa deixá-lo inalterado. Conflitar os dois — ou ser inconsistente sobre quais campos podem legitimamente ser `null` — produz APIs em que os clientes não conseguem distinguir com segurança 'limpe este valor' de 'não enviei atualização para este campo'.

Paginação: cursor vs. offset, e como cada uma aparece em JSON

Paginação por offset/limit (`?offset=40&limit=20`, ou `?page=3&pageSize=20`) é a mais simples de implementar e raciocinar, mas tem um problema real de correção: se linhas forem inseridas ou excluídas entre as requisições de página, os offsets deslocam por baixo do cliente, causando linhas puladas ou duplicadas. Ela também fica cara em SQL em offsets profundos, já que o banco ainda precisa percorrer e descartar todas as linhas puladas.

A paginação por cursor entrega ao cliente um token opaco apontando para 'a linha seguinte à última que você viu' em vez de uma posição numérica, então permanece estável mesmo quando os dados subjacentes mudam, e continua barata em qualquer profundidade. A contrapartida é que os clientes não conseguem pular para um número de página arbitrário — apenas avançar (e, se houver suporte, retroceder) a partir de um cursor.

Uma resposta com paginação por cursor — sem aritmética de offset, apenas um token opaco de próxima página.
json
{
  "data": [ { "id": 101 }, { "id": 102 } ],
  "meta": {
    "nextCursor": "eyJpZCI6MTAyfQ==",
    "hasMore": true
  }
}

Padronize na paginação por cursor para qualquer coisa apoiada em dados que mudam com frequência (feeds, logs, fluxos de eventos) ou tabelas grandes; a paginação por offset funciona bem para coleções pequenas e majoritariamente estáticas em que pular para a página N é genuinamente útil (uma tabela administrativa com seletor de páginas).

Objetos de erro e convenções de nomenclatura

Uma resposta de erro deve carregar mais do que um código de status HTTP e uma string: um `code` estável e legível por máquina no qual o cliente possa ramificar com segurança (`"VALIDATION_ERROR"`, não uma frase), uma `message` legível por humanos destinada a logs e desenvolvedores, e, para falhas de validação, um array `details` indicando exatamente quais campos falharam e por quê.

Um formato de erro realmente acionável para um cliente.
json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request failed validation",
    "details": [
      { "field": "email", "message": "must be a valid email address" },
      { "field": "age", "message": "must be at least 0" }
    ]
  }
}

Para nomenclatura de chaves, camelCase e snake_case são escolhas igualmente boas — o que realmente machuca é misturá-las entre endpoints da mesma API, o que obriga todo cliente a escrever lógica de mapeamento de campos inconsistente dependendo de qual endpoint está chamando. Consumidores focados em JavaScript/TypeScript tendem a preferir camelCase, pois combina com a desestruturação nativa; ecossistemas focados em Python e Ruby costumam preferir snake_case. Escolha um para a API inteira e aplique-o consistentemente também aos booleanos (`isActive`/`hasAccess`, não uma mistura de `active` e `has_access`).

Antipadrões comuns

JSON stringificado dentro de um campo JSON — `"metadata": "{\"plan\":\"pro\"}"` em vez de `"metadata": { "plan": "pro" }` — obriga todo cliente a analisar duas vezes e joga fora a segurança de tipos sem nenhum benefício. Quase sempre é sinal de que o servidor serializou verbatim uma coluna blob do banco em vez de decodificá-la antes de enviar a resposta.

Formatos de data inconsistentes — alguns campos como timestamps Unix, outros como `"08/15/2026"`, outros como ISO 8601 completo — forçam os clientes a escrever análise de data por campo em vez de um único handler compartilhado. Padronize em ISO 8601 em UTC com offset explícito (`"2026-08-15T09:30:00Z"`) em toda parte.

Vazamento de campos internos do banco — retornar artefatos de ORM como `password_hash`, chaves estrangeiras internas destinadas a outro serviço, ou campos de contabilidade do ORM (`__v`, `_id`, `created_by_worker_id`) verbatim numa resposta de API. Sempre mapeie seu modelo de banco para um DTO de resposta explícito em vez de serializar o modelo diretamente; é a única forma confiável de garantir que uma mudança de schema interna não se torne silenciosamente uma mudança na API pública.

FAQ

Respostas de erro devem usar o mesmo envelope das respostas de sucesso?
Sim. Se as respostas de sucesso são `{ "data": ..., "meta": ... }`, as respostas de erro devem ter o mesmo formato de nível superior com `data: null` e um objeto `error` preenchido, em vez de uma estrutura totalmente diferente. Isso permite que o código de tratamento de resposta do cliente verifique uma única coisa — a presença de `error` — em vez de ramificar pelo formato da resposta a cada código de status.
camelCase ou snake_case — a escolha realmente importa?
Não muito por si só, mas a consistência importa muito. Escolha uma convenção para a API inteira com base no ecossistema dos seus principais consumidores, documente-a e aplique-a em toda parte — incluindo prefixos booleanos e chaves de objetos aninhados. O custo não é a convenção em si, é uma API em que endpoints diferentes usam convenções diferentes e todo cliente precisa de lógica de mapeamento específica por endpoint.
Uma API nova deve usar paginação por cursor ou por offset?
Padronize na paginação por cursor, a menos que você especificamente precise permitir que usuários saltem para um número de página arbitrário em dados pequenos e majoritariamente estáticos. A paginação por cursor permanece correta quando linhas são adicionadas ou removidas no meio da paginação e continua barata em qualquer profundidade; a paginação por offset degrada em correção e desempenho à medida que o conjunto de dados cresce ou muda.
Por que não simplesmente serializar meu modelo de banco diretamente como resposta da API?
Porque seu schema de banco e seu contrato de API pública mudam por razões diferentes e em ritmos diferentes. Serializar o modelo diretamente significa que toda migração, coluna adicionada ou upgrade de ORM arrisca mudar silenciosamente — ou vazar — o formato da sua resposta pública. Um DTO de resposta explícito desacopla os dois, ao custo de uma etapa extra de mapeamento por endpoint.

Experimente estas ferramentas

Artigos relacionados