Arquitetura de software
Design de APIs REST: recursos, semântica HTTP, versionamento e formatos de erro
Intermediário11 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
Uma API REST é um contrato com o qual você vai conviver por anos, e a maior parte do seu custo de longo prazo é decidida na primeira semana. Este guia cobre a modelagem de recursos, a semântica HTTP definida pela RFC 9110, o design de representações e de erros com problem details, paginação e filtragem, cache com ETags e as estratégias de versionamento que permitem que uma API mude sem quebrar seus consumidores.
O que você vai aprender
- Modelar recursos e URIs que reflitam o domínio, e não o banco de dados
- Aplicar corretamente a semântica HTTP de métodos, códigos de status e requisições condicionais
- Projetar paginação, filtragem e ordenação que continuem corretas quando os dados mudam
- Retornar erros legíveis por máquina usando os problem details da RFC 9457
- Evoluir uma API de forma compatível e escolher uma estratégia de versionamento sustentável
Uma API HTTP é a interface mais duradoura que a maioria dos sistemas chega a expor. O serviço por trás dela será reescrito, migrado para outra plataforma e talvez substituído por completo, enquanto a API continua atendendo clientes construídos por pessoas que saíram da empresa há muito tempo. Essa assimetria é o motivo para investir um esforço real de design logo no início: o código interno pode ser refatorado numa terça à tarde, mas um nome de campo publicado é praticamente permanente.
Um bom design REST consiste principalmente em usar o HTTP como ele foi especificado, em vez de encapsular nele um protocolo feito sob medida. A RFC 9110 define o que os métodos significam, o que os códigos de status significam e como funcionam as requisições condicionais, e uma API que respeita essas definições ganha de graça cache, segurança para novas tentativas, comportamento correto de proxies e suporte das bibliotecas cliente. Uma API que as ignora precisa reinventar tudo isso, e mal.
Recursos e URIs
Um recurso é algo do domínio que merece ter um endereço: um pedido, um cliente, um pagamento, a coleção de transações de uma conta. Não é uma tabela do banco de dados, e modelar uma URI por tabela é justamente o que leva APIs a expor decisões de schema que depois não podem mais mudar.
Convenções que se sustentam bem:
- Substantivos no plural para coleções, com o identificador acrescentado para um item:
/orders,/orders/8f21c. A consistência importa mais do que a escolha em si. - Aninhamento só quando o filho não pode existir sozinho.
/orders/8f21c/linesé razoável, porque uma linha não tem sentido fora do seu pedido./customers/42/ordersfica melhor como/orders?customer=42quando os pedidos passam a ser endereçáveis por conta própria, e aninhar três níveis é sempre um erro. - Nada de verbos nos caminhos. O verbo é o método.
/orders/8f21c/cancelé uma exceção pragmática comum, à qual vale resistir pelo menos uma vez: a alternativa é um campo de estado atualizado comPATCH, ou um sub-recurso/orders/8f21c/cancellationcriado comPOST, que ainda te dá um lugar para registrar quem cancelou e por quê. - Kebab-case, minúsculas, sem extensão de arquivo e sem barra no final, escolhidos uma vez e aplicados em todo lugar.
- Identificadores opacos. Inteiros sequenciais revelam volumes e convidam à enumeração. UUIDs ou identificadores aleatórios curtos não custam nada a mais.
O ponto de fundo é que as URIs fazem parte do contrato público. Se uma URI codifica a sua hierarquia interna, reorganizar essa hierarquia vira uma breaking change.
Semântica dos métodos
A RFC 9110 atribui a cada método duas propriedades que importam muito na prática: se ele é seguro (não altera estado) e se é idempotente (repeti-lo tem o mesmo efeito que executá-lo uma vez). A infraestrutura inteira depende disso — proxies podem repetir requisições idempotentes, navegadores podem fazer prefetch das seguras —, então violar essas propriedades causa bugs que você nunca vai reproduzir localmente.
| Método | Seguro | Idempotente | Uso |
|---|---|---|---|
| GET | Sim | Sim | Obter uma representação; nunca altera estado, nunca recebe corpo |
| HEAD | Sim | Sim | Só headers — verificar tamanho ou existência |
| POST | Não | Não | Criar um recurso subordinado ou enviar uma solicitação de processamento |
| PUT | Não | Sim | Criar ou substituir um recurso em uma URI conhecida |
| PATCH | Não | Não | Aplicar uma modificação parcial |
| DELETE | Não | Sim | Remover um recurso; uma segunda chamada retorna 404 ou 204 |
| OPTIONS | Sim | Sim | Informar os métodos suportados; usado pelo preflight de CORS |
O fato de POST não ser idempotente é o problema prático: um cliente que sofre um timeout não tem como saber se o pedido foi criado. A solução é uma chave de idempotência — um identificador gerado pelo cliente, enviado em um header, armazenado no servidor junto com o resultado e reaproveitado em caso de repetição. A implementação é discutida em padrões de design de microsserviços.
Para PATCH, escolha um media type definido em vez de inventar uma semântica. application/merge-patch+json envia um objeto com os campos a alterar, usando null para remover; application/json-patch+json (RFC 6902) envia uma lista ordenada de operações e consegue apontar posições de um array. O merge patch atende à maioria das necessidades; o JSON Patch vale a pena quando os clientes editam listas.
Códigos de status
Use um conjunto pequeno com precisão, em vez de usar um conjunto grande por aproximação.
| Código | Significado na prática |
|---|---|
| 200 OK | Sucesso com corpo |
| 201 Created | Recurso criado; inclua um header Location apontando para ele |
| 202 Accepted | Requisição aceita para processamento assíncrono; retorne uma URI de status |
| 204 No Content | Sucesso sem nada a retornar, tipicamente em DELETE ou PUT |
| 304 Not Modified | GET condicional cujo validador ainda confere |
| 400 Bad Request | Sintaxe malformada ou corpo que não pode ser parseado |
| 401 Unauthorized | Credenciais ausentes ou inválidas — a autenticação falhou |
| 403 Forbidden | Autenticado, mas sem permissão |
| 404 Not Found | O recurso não existe ou está deliberadamente oculto para este chamador |
| 409 Conflict | A requisição conflita com o estado atual — duplicidade, versão divergente |
| 412 Precondition Failed | O If-Match de uma requisição condicional não foi satisfeito |
| 422 Unprocessable Content | Sintaticamente válido, semanticamente inválido — falhas de validação |
| 429 Too Many Requests | Limite de taxa atingido; inclua Retry-After |
| 500 / 502 / 503 / 504 | Falha do servidor, resposta inválida do upstream, indisponível, timeout do upstream |
Duas distinções causam a maior parte da confusão. 401 significa "não sei quem você é"; 403 significa "sei, e você não pode". E 400 versus 422: reserve 400 para corpos que o parser rejeita e use 422 quando o JSON foi parseado sem problemas, mas o conteúdo viola uma regra de negócio. Nunca retorne 200 com um objeto de erro no corpo — toda biblioteca cliente, todo proxy e todo sistema de monitoramento trata 200 como sucesso, e você terá tornado as falhas invisíveis. O catálogo completo está em códigos de status HTTP.
Erros com os quais o cliente consegue agir
A RFC 9457 define application/problem+json, uma estrutura padrão e enxuta que dispensa cada API de inventar seu próprio envelope de erro.
{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Insufficient funds",
"status": 422,
"detail": "Account FR76…0189 has a balance of 12.40 EUR; the transfer requires 250.00 EUR.",
"instance": "/transfers/9c1a4b7e",
"balance": "12.40",
"currency": "EUR",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
As regras que a tornam útil: type é uma URI estável que identifica o tipo de problema e é nela que os clientes se baseiam para decidir o que fazer, então ela nunca deve mudar de significado; title é um resumo curto e legível que permanece constante para um mesmo type; detail explica esta ocorrência específica e pode variar; membros de extensão carregam os dados estruturados de que o cliente precisa. Inclua um identificador de trace para que um chamado de suporte possa ser associado a um trace distribuído.
Em falhas de validação, retorne todos os problemas de uma vez, e não só o primeiro, para que um formulário possa destacar todos os campos inválidos em uma única ida e volta.
{
"type": "https://api.example.com/problems/validation",
"title": "Request validation failed",
"status": 422,
"errors": [
{ "field": "email", "code": "format", "message": "must be a valid email address" },
{ "field": "quantity", "code": "min", "message": "must be at least 1" }
]
}
Nunca deixe vazar stack traces, trechos de SQL ou nomes de hosts internos em um detail. Registre isso no log com o identificador de trace e retorne o identificador no lugar.
Coleções: paginação, filtragem e ordenação
Todo endpoint de coleção vai acabar contendo mais linhas do que uma resposta deveria carregar, então pagine desde o primeiro dia e nunca retorne uma lista sem limite.
A paginação por offset (?page=3&size=50 ou ?offset=100&limit=50) é simples e permite ao cliente pular para uma página qualquer. Suas fraquezas são reais: o banco percorre e descarta tudo o que vem antes do offset, então a página 500 é muito mais lenta que a página 1, e inserções concorrentes deslocam linhas entre as requisições, fazendo itens serem pulados ou duplicados.
A paginação por cursor retorna um token opaco que codifica a posição em uma ordenação estável, e o cliente o envia de volta para obter a próxima página. Páginas profundas custam o mesmo que as iniciais, e escritas concorrentes não causam saltos.
GET /orders?status=confirmed&sort=-created_at&limit=50 HTTP/1.1
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
{
"items": [ { "id": "8f21c", "status": "confirmed", "total": "89.90" } ],
"next": "/orders?status=confirmed&sort=-created_at&limit=50&cursor=eyJjIjoiMjAyNi0wOS0wN1QwOTo0MSJ9"
}
Seja qual for a escolha, aplique a mesma disciplina: ordene sempre por algo único (acrescente a chave primária como critério de desempate, ou a paginação se torna não determinística), limite limit a um máximo documentado e use um vocabulário fixo de query parameters para os filtros, em vez de aceitar expressões arbitrárias — uma API que aceita uma string de filtro é uma API cujo planejador de consultas passou a fazer parte do seu contrato público.
Representações e negociação de conteúdo
Os clientes informam o que aceitam com Accept; os servidores descrevem o que enviaram com Content-Type. Suporte bem um media type principal — quase sempre application/json — e só acrescente outros quando um consumidor real precisar.
Algumas convenções valem ser definidas cedo, porque mudá-las depois quebra todo mundo: um único padrão de nomenclatura para os campos (camelCase ou snake_case, escolhido uma vez); timestamps RFC 3339 em UTC com offset explícito; valores monetários como strings com um campo de moeda explícito, nunca como números de ponto flutuante; enumerações como tokens estáveis em maiúsculas, e não como texto de exibição; e significados distintos e documentados para campo ausente e campo nulo.
Não embrulhe toda resposta em um envelope genérico como {"data": …, "meta": …}. O HTTP já fornece status, headers e corpo; o envelope acrescenta uma camada que os clientes precisam desembrulhar e duplica informações que o protocolo já carrega. Metadados que realmente acompanham uma coleção — links de paginação, contagens totais — são a exceção razoável.
Cache e concorrência
Respostas de GET podem carregar validadores que permitem a clientes e proxies evitar retransferir dados que não mudaram, e esses mesmos validadores resolvem o problema de atualizações perdidas nas escritas.
GET /orders/8f21c HTTP/1.1
If-None-Match: "v14"
HTTP/1.1 304 Not Modified
ETag: "v14"
Cache-Control: private, max-age=60
Um ETag é um validador opaco para uma representação específica — um número de versão, um hash do conteúdo. Um cliente que tem um ETag envia If-None-Match e recebe 304 sem corpo quando nada mudou. Nas escritas, o cliente envia If-Match: "v14" no seu PUT ou PATCH; se o recurso tiver mudado nesse meio-tempo, o servidor retorna 412 Precondition Failed e o cliente pode recarregar e tentar de novo, em vez de sobrescrever silenciosamente a alteração de outra pessoa. É controle de concorrência otimista implementado no próprio protocolo, e adicioná-lo custa quase nada.
Defina Cache-Control de forma deliberada em toda resposta: private para tudo o que for específico de um usuário, no-store para tudo o que for sensível e um max-age modesto para dados de referência lidos por muitos clientes.
Versionamento e compatibilidade
A versão mais barata é aquela que você nunca precisa lançar, então esgote primeiro a evolução compatível. Adicionar um campo opcional, adicionar um endpoint, adicionar um valor de enum que a documentação diz que os clientes devem tolerar, relaxar uma regra de validação — tudo isso é compatível se os clientes ignorarem campos desconhecidos, o que a sua documentação precisa exigir. Remover um campo, renomeá-lo, mudar um tipo, endurecer uma validação ou alterar um código de status são todos breaking changes.
Quando a quebra é inevitável:
| Estratégia | Exemplo | Trade-off |
|---|---|---|
| Caminho da URI | /v2/orders | Óbvio, fácil de rotear e de cachear, mas a versão fica gravada em toda URI do cliente |
| Media type | Accept: application/vnd.example.order.v2+json | Preciso e por recurso; clientes e ferramentas costumam lidar mal com ele |
| Header customizado | X-API-Version: 2 | Mantém as URIs estáveis; invisível em logs e em testes pelo navegador, a menos que você procure |
| Query parameter | /orders?version=2 | Simples; interage mal com o cache |
Versionar pelo caminho vence em simplicidade operacional para a maioria dos times, e seu principal custo — a granularidade grossa — é administrável se você versionar raramente. Seja qual for a escolha, versione a API inteira em vez de endpoints isolados, mantenha a versão anterior no ar enquanto os consumidores migram, publique um header Sunset e uma data de descontinuação na versão antiga e meça o tráfego por versão, para que a desativação seja uma decisão baseada em evidências e não em esperança.
Hipermídia, na medida certa
A restrição de hipermídia diz que um cliente deve descobrir o que pode fazer em seguida a partir dos links na resposta, e não de URIs fixas no código. Aplicada por completo, ela desacopla os clientes da sua estrutura de URIs de forma permanente.
Sendo honesto, pouquíssimos clientes são escritos para navegar dinamicamente, e os formatos genéricos de hipermídia acrescentam uma cerimônia que a maioria dos times acaba abandonando. O que compensa é o uso direcionado: links de paginação (os clientes realmente seguem next), links para recursos relacionados que poupam uma consulta e links que representam as transições de estado disponíveis, o que permite ao servidor dizer a uma interface que um pedido pode ser cancelado mas não reembolsado, sem que o cliente precise reimplementar essa regra.
{
"id": "8f21c",
"status": "CONFIRMED",
"total": { "amount": "89.90", "currency": "EUR" },
"_links": {
"self": { "href": "/orders/8f21c" },
"lines": { "href": "/orders/8f21c/lines" },
"cancel": { "href": "/orders/8f21c/cancellation", "method": "POST" }
}
}
Descrevendo a API
Um documento OpenAPI é o contrato legível por máquina: caminhos, operações, schemas, esquemas de segurança, exemplos. Ele gera bibliotecas cliente, alimenta servidores de mock, viabiliza testes de contrato e abastece o portal do desenvolvedor de uma plataforma de API management.
Existem duas abordagens. Design first significa escrever o documento OpenAPI antes do código, revisá-lo com os consumidores e gerar os stubs do servidor — melhor para APIs públicas ou de parceiros, em que o contrato é negociado. Code first gera o documento a partir da implementação anotada, como faz o springdoc, o que o mantém preciso com menos esforço e combina com APIs internas. Em qualquer um dos casos, mantenha o documento no controle de versão, revise o diff dele no code review para que as breaking changes fiquem visíveis e inclua exemplos realistas — o exemplo é a parte que os desenvolvedores de fato leem.
Principais pontos
- Projete as URIs em torno de recursos do domínio, não de tabelas, e trate todo caminho e todo nome de campo publicados como praticamente permanentes.
- Respeite as propriedades de segurança e idempotência definidas na RFC 9110; onde
POSTnão puder ser idempotente, adicione uma chave de idempotência. - Use um conjunto pequeno e preciso de códigos de status e nunca sinalize uma falha com
200. - Retorne erros como problem details da RFC 9457, com uma URI
typeestável, extensões estruturadas e um identificador de trace. - Pagine toda coleção, ordene por uma chave única e prefira cursores quando os dados mudam durante a leitura ou as páginas ficam profundas.
- Evolua de forma compatível adicionando em vez de alterando; quando a quebra for inevitável, versione a API inteira, anuncie uma data de desativação e meça o uso antes de desativar qualquer coisa.
Fontes e leituras complementares
Perguntas frequentes
- Devo usar PUT ou PATCH para atualizações?
- PUT substitui o recurso inteiro pela representação que você envia, então omitir um campo significa apagá-lo. PATCH aplica uma modificação parcial descrita no corpo da requisição. A maioria dos clientes reais quer atualizações parciais, por isso PATCH é a escolha comum; defina o media type explicitamente: application/merge-patch+json para atualizações simples de campos ou application/json-patch+json quando você precisar de operações sobre arrays.
- Paginação por offset é tão ruim assim?
- Ela funciona bem para conjuntos de dados pequenos e estáveis e para interfaces que precisam pular direto para a página 40. Em escala, tem dois problemas reais: o banco precisa percorrer e descartar tudo o que vem antes do offset, então as páginas profundas ficam mais lentas, e linhas inseridas ou removidas entre requisições fazem itens serem pulados ou repetidos. A paginação por cursor baseada em uma chave de ordenação estável evita os dois.
- Preciso implementar HATEOAS para chamar a API de REST?
- Pela definição original, sim: é a restrição de hipermídia que permite aos clientes descobrir as transições em vez de fixar URIs no código. Na prática, pouquíssimos clientes navegam pelos links dinamicamente, e a hipermídia completa raramente compensa o custo. O meio-termo pragmático é incluir links para as transições de estado reais e para a paginação, que os clientes de fato seguem, e documentar todo o resto no OpenAPI.
- Por quanto tempo uma versão antiga da API deve ser mantida?
- Pelo tempo necessário para o consumidor mais lento que importa para você: em APIs públicas, isso costuma significar pelo menos doze meses e, em clientes mobile, no mínimo o tempo de vida da versão mais antiga do app que você ainda suporta. Anuncie uma data de desativação quando a nova versão for lançada, publique o header Sunset nos endpoints descontinuados e instrumente o uso por versão para provar quem ainda está chamando antes de desligar qualquer coisa.