Spring Boot e Spring Cloud
APIs REST com Spring Boot 3: controllers, DTOs, códigos de status e clientes
Intermediário9 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
Você vai aprender a construir uma API JSON limpa com Spring Boot 3: mapear recursos para controllers, usar records como corpos de requisição e resposta, devolver os códigos de status e headers corretos, lidar com paginação e negociação de conteúdo, configurar CORS de forma consciente e chamar outros serviços com o RestClient introduzido no Spring Framework 6.1.
O que você vai aprender
- Estruturar uma API orientada a recursos com @RestController e as anotações de mapeamento compostas
- Usar records Java como DTOs de requisição e resposta e manter as entidades JPA fora do contrato da API
- Devolver os códigos de status HTTP, headers Location e corpos ResponseEntity corretos para criação, atualização e exclusão
- Oferecer paginação, ordenação e negociação de conteúdo sem vazar detalhes de persistência
- Chamar serviços HTTP externos com RestClient e clientes declarativos do tipo HTTP interface
A maioria dos serviços Spring Boot existe para expor uma API HTTP que outros programas chamam. O Spring MVC deixa a mecânica fácil, e é justamente por isso que tantas APIs acabam inconsistentes: um endpoint devolve 200 na criação, outro devolve entidades que disparam exceções de carregamento lazy, um terceiro aceita um tamanho de página de um milhão. Esta página mostra como construir uma API que se comporta como uma API bem projetada, da camada de controllers aos DTOs, códigos de status e paginação, e termina no lado do cliente, porque um serviço dentro de um sistema de serviços também é consumidor. Ela pressupõe Spring Boot 3.2 ou mais recente, para que o RestClient esteja disponível.
O ciclo de vida de uma requisição em um parágrafo
Uma requisição chega ao Tomcat embutido, passa pela cadeia de filtros de servlet (segurança, CORS, tracing) e cai no DispatcherServlet. O dispatcher pergunta aos seus handler mappings qual método de controller corresponde ao caminho e ao método HTTP, converte as variáveis de caminho, os parâmetros de query e o corpo em argumentos do método, invoca o método e entrega o valor de retorno a um conversor de mensagens que escreve JSON (Jackson, por padrão). Exceções sobem até os exception handlers, que produzem uma resposta de erro. Tudo o que você configura fica em um desses pontos, então saber onde uma funcionalidade vive (filtro, argument resolver, conversor de mensagens, exception handler) diz como personalizá-la.
Controllers orientados a recursos
Uma API REST modela recursos endereçados por URLs e manipulados com métodos HTTP. No Spring, isso se traduz em um controller por agregado, com o caminho no nível da classe e os verbos no nível dos métodos:
package com.example.shop.order;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderService orders;
OrderController(OrderService orders) {
this.orders = orders;
}
@GetMapping("/{id}")
OrderResponse get(@PathVariable long id) {
return OrderResponse.from(orders.get(id)); // throws OrderNotFoundException -> 404
}
@PostMapping
ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest request) {
Order created = orders.create(request.customerId(), request.lines());
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(OrderResponse.from(created));
}
@PutMapping("/{id}/shipping-address")
OrderResponse changeAddress(@PathVariable long id, @RequestBody Address address) {
return OrderResponse.from(orders.changeShippingAddress(id, address));
}
@DeleteMapping("/{id}")
@ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT)
void cancel(@PathVariable long id) {
orders.cancel(id);
}
}
Várias dessas escolhas são deliberadas. A injeção por construtor permite testar o controller sem container. O controller não tem regra de negócio: ele traduz HTTP em chamadas ao serviço e objetos de domínio em respostas. @ResponseStatus é usado quando o status é fixo, e ResponseEntity quando o status ou os headers dependem do resultado. O caminho usa substantivos e um sub-recurso aninhado (/shipping-address) em vez de um verbo como /updateAddress.
O Spring MVC faz o matching de caminhos com PathPatternParser desde o Boot 2.6, e o Spring Framework 6 deixou de aceitar barra final por padrão: /api/orders/ não é mais roteado para /api/orders. Se algum cliente depende do comportamento antigo, adicione um redirecionamento ou configure o UrlHandlerFilter em vez de reverter a opção global.
Vinculando os dados da requisição
| Anotação | Vincula | Uso típico |
|---|---|---|
@PathVariable | Um segmento do template de URI | Identificadores de recursos |
@RequestParam | Um parâmetro de query ou de formulário | Filtros, paginação, flags; required = false e defaultValue para os opcionais |
@RequestBody | O corpo inteiro, via conversor de mensagens | Payloads JSON |
@RequestHeader | Um header | IDs de correlação, requisições condicionais |
@ModelAttribute (implícito) | Vários parâmetros de query em um único objeto | Formulários de busca com vários campos opcionais |
Desde o Spring Framework 6.1, os nomes dos parâmetros deixaram de estar disponíveis via reflection, a menos que o código seja compilado com -parameters; o POM pai do Boot e o plugin do Gradle ativam essa flag, mas um build customizado precisa adicioná-la, senão cada @PathVariable vai exigir um nome explícito.
Records como DTOs
Use records nos dois sentidos. Eles são imutáveis, concisos, e o Jackson (incluído no starter web) os serializa e desserializa sem nenhuma anotação:
public record CreateOrderRequest(long customerId, List<OrderLine> lines) {
public record OrderLine(String sku, int quantity) {}
}
public record OrderResponse(long id, String status, Money total, List<OrderLine> lines) {
static OrderResponse from(Order order) {
return new OrderResponse(order.id(), order.status().name(),
order.total(), order.lines().stream().map(OrderLine::from).toList());
}
}
Manter as entidades JPA fora do contrato evita três problemas recorrentes: coleções lazy que explodem em LazyInitializationException durante a serialização, associações bidirecionais que entram em recursão infinita e a exposição acidental de colunas que ninguém pretendia publicar. Os métodos de mapeamento são chatos de escrever, mas fáceis de ler; o MapStruct gera esses métodos se a quantidade crescer.
Algumas configurações do Jackson que quase toda API quer ficam em propriedades, e não em anotações:
spring.jackson.default-property-inclusion=non_null
spring.jackson.serialization.write-dates-as-timestamps=false
spring.jackson.deserialization.fail-on-unknown-properties=false
Com a segunda configuração, os tipos de java.time são serializados como strings ISO-8601; a primeira remove os nulos das respostas; a terceira é o padrão do Boot e mantém clientes antigos funcionando quando você adiciona campos. Quando precisar proibir campos desconhecidos em um tipo de requisição específico, anote esse record com @JsonIgnoreProperties(ignoreUnknown = false).
Códigos de status e headers que significam algo
A semântica HTTP (RFC 9110) é o contrato que seus clientes já entendem, então use-a em vez de inventar um envelope com "success": true:
| Situação | Status | Observações |
|---|---|---|
| Leitura bem-sucedida | 200 OK | O corpo é a representação |
| Recurso criado | 201 Created | Header Location; corpo opcional, mas útil |
| Aceito para processamento assíncrono | 202 Accepted | O corpo ou um header diz ao cliente onde consultar o andamento |
| Atualização ou exclusão sem nada a devolver | 204 No Content | Nenhum corpo |
| Entrada malformada ou inválida | 400 Bad Request | Ou 422 quando a sintaxe está certa mas a semântica não |
| Não autenticado / proibido | 401 / 403 | Produzidos pelo Spring Security |
| Recurso desconhecido | 404 Not Found | Também para IDs válidos que o chamador não pode ver, se você oculta a existência deles |
| Conflito de versão, duplicidade | 409 Conflict | Lock otimista, restrições de unicidade |
| Falha inesperada | 500 Internal Server Error | Nunca vaze stack traces |
ResponseEntity é a ferramenta para tudo que vai além de um status fixo:
@GetMapping("/{id}")
ResponseEntity<OrderResponse> get(@PathVariable long id,
@RequestHeader(value = "If-None-Match", required = false) String etag) {
Order order = orders.get(id);
String current = "\"" + order.version() + "\"";
if (current.equals(etag)) {
return ResponseEntity.status(304).eTag(current).build();
}
return ResponseEntity.ok().eTag(current).body(OrderResponse.from(order));
}
Para o lado dos erros (traduzir exceções em respostas ProblemDetail), veja o guia de validação e tratamento de erros; o ponto aqui é que o caminho feliz já precisa ser preciso. A referência de códigos de status HTTP, na seção Web, lista cada código e explica quando usá-lo.
Paginação, ordenação e filtragem
Coleções sem limite são o bug de desempenho mais comum em APIs. O Pageable do Spring Data pode ser injetado direto em um método de controller quando o suporte web do Spring Data está no classpath, o que acontece com o spring-boot-starter-data-jpa:
@GetMapping
Page<OrderSummary> list(@RequestParam(required = false) String status,
@PageableDefault(size = 20, sort = "createdAt") Pageable pageable) {
return orders.search(status, pageable).map(OrderSummary::from);
}
Os clientes enviam ?page=0&size=20&sort=createdAt,desc. Limite o tamanho (spring.data.web.pageable.max-page-size, que por padrão é 2000, costuma ser generoso demais para uma API) e considere devolver um formato de página estável e mínimo. Desde o Spring Data 3.3, serializar uma Page diretamente gera um warning no log, porque o formato JSON dela não é tratado como contrato estável; defina spring.data.web.pageable.serialization-mode=via_dto para obter a estrutura mais simples do PagedModel, ou encapsule a página em um record seu, para que o contrato não mude quando uma biblioteca mudar. Paginação por offset funciona bem em telas administrativas; para feeds e tabelas grandes, prefira a paginação por chave (keyset, ?after=<last id>), que continua rápida qualquer que seja a profundidade.
A filtragem vai nos parâmetros de query (?status=SHIPPED&customerId=42), nunca no corpo de um GET. Se uma busca for complexa a ponto de precisar de corpo, modele-a como um POST /api/orders/search e documente que ela é segura e idempotente.
Negociação de conteúdo e versionamento
O Spring MVC escolhe o formato da resposta a partir do header Accept. Com apenas o Jackson presente, a resposta é sempre JSON; adicione jackson-dataformat-xml e o mesmo controller passa a servir XML para o cliente que pedir. Ativar o parâmetro de query format (spring.mvc.contentnegotiation.favor-parameter=true) ajuda quando navegadores ou proxies adulteram os headers.
Versionamento não tem uma única resposta certa. Prefixos de URL (/api/v2/orders) são os mais fáceis de rotear, cachear e documentar; um media type customizado (Accept: application/vnd.shop.v2+json) mantém as URLs estáveis ao custo de ficarem mais difíceis de descobrir; um header (X-API-Version: 2) é simples, mas invisível para caches. Seja qual for a escolha, crie uma nova versão apenas para mudanças incompatíveis e faça as mudanças aditivas (novos campos opcionais) sem mudar de versão.
CORS
Navegadores recusam chamadas JavaScript entre origens diferentes, a menos que o servidor as permita explicitamente. Uma single-page application servida a partir de https://shop.example.com que chama https://api.example.com precisa de uma política de Cross-Origin Resource Sharing (CORS):
@Configuration
class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://shop.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Authorization", "Content-Type")
.maxAge(3600);
}
}
@CrossOrigin em um controller faz o mesmo por classe ou por método. Com o Spring Security na cadeia, o CORS também precisa ser habilitado lá (http.cors(Customizer.withDefaults())), para que a requisição OPTIONS de preflight não seja rejeitada antes de chegar ao MVC. Navegadores recusam origens curinga combinadas com credenciais, e um curinga sozinho significa que qualquer site pode chamar sua API a partir do navegador de um usuário logado; liste as origens que você realmente atende.
Chamando outros serviços
O Spring Framework 6.1 adicionou o RestClient, um cliente síncrono com o estilo fluente do WebClient e sem dependência reativa. O Boot 3.2 autoconfigura um RestClient.Builder que já traz os conversores de mensagens e quaisquer beans RestClientCustomizer (headers de tracing do Micrometer, por exemplo), então injete o builder em vez de chamar RestClient.create():
@Service
class InventoryClient {
private final RestClient client;
InventoryClient(RestClient.Builder builder,
@Value("${app.inventory.base-url}") String baseUrl) {
this.client = builder.baseUrl(baseUrl).build();
}
StockLevel stockFor(String sku) {
return client.get()
.uri("/stock/{sku}", sku)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError,
(req, res) -> { throw new UnknownSkuException(sku); })
.body(StockLevel.class);
}
}
Para um cliente com mais operações, declare uma HTTP interface e deixe o Spring gerar a implementação:
interface InventoryApi {
@GetExchange("/stock/{sku}")
StockLevel stock(@PathVariable String sku);
@PostExchange("/reservations")
Reservation reserve(@RequestBody ReservationRequest request);
}
@Bean
InventoryApi inventoryApi(RestClient.Builder builder, AppProperties props) {
RestClient client = builder.baseUrl(props.inventory().baseUrl()).build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(client))
.build()
.createClient(InventoryApi.class);
}
Qualquer que seja o cliente, defina timeouts de conexão e de leitura (spring.http.client.connect-timeout e read-timeout no Boot 3.4+, ou pela request factory antes disso). Um cliente sem timeout de leitura transforma uma dependência lenta em esgotamento de threads no seu próprio serviço.
Erros comuns
Verbos nas URLs e tudo como POST. /api/getOrder?id=1 joga fora o cache, a semântica dos métodos e a capacidade das ferramentas de entender a API. Mapeie leituras para GET, criação para POST, substituição completa para PUT, atualizações parciais para PATCH e remoção para DELETE.
Devolver 200 para tudo, com uma flag de erro no corpo. Clientes, proxies, monitoramento e lógica de retry se baseiam no código de status. Um 200 que significa "falhou" é invisível para todos eles.
Expor entidades e contar com @JsonIgnore para esconder campos. Funciona até alguém adicionar um campo, e acopla o schema à API. Mapeie para records.
Open Session in View mascarando o carregamento lazy. O Boot ativa spring.jpa.open-in-view por padrão e registra um warning sobre isso. O contexto de persistência fica aberto durante a serialização JSON, então as associações lazy são carregadas uma consulta por vez enquanto a resposta é escrita. Defina como false e carregue o que o DTO precisa no serviço ou no repository.
Tamanhos de página sem limite e timeouts ausentes. Os dois funcionam bem em desenvolvimento e são catastróficos sob carga; defina limites desde o primeiro dia.
Ignorar -parameters. Um build Maven ou Gradle customizado sem essa flag faz todo @PathVariable long id falhar em tempo de execução no Spring Framework 6.1+, com uma mensagem sobre nomes de parâmetros ausentes.
Principais pontos
- Modele recursos como controllers, com caminhos no nível da classe e verbos no nível dos métodos; mantenha os controllers enxutos e delegue aos serviços.
- Use records como DTOs de requisição e resposta e nunca serialize entidades JPA diretamente.
- Devolva os códigos de status e os headers que o HTTP já define: 201 com
Locationna criação, 204 para sucesso sem conteúdo, 4xx para erros do cliente. - Limite todo endpoint de coleção com paginação e exponha um formato de página estável; prefira paginação por chave para listas profundas ou de alto tráfego.
- Habilite o CORS explicitamente para as origens que você atende, tanto no MVC quanto no Spring Security.
- Chame outros serviços com
RestClientou com uma HTTP interface construída a partir do builder autoconfigurado pelo Boot, e sempre defina timeouts.
Fontes e leituras complementares
Perguntas frequentes
- Devo devolver entidades JPA diretamente de um controller?
- Evite. Entidades carregam associações lazy que serializam mal ou disparam consultas extras, expõem a estrutura do banco de dados como contrato da API, e qualquer campo que você adicione por motivos de persistência vira público sem ninguém perceber. Um DTO baseado em record por endpoint permite que a API e o schema evoluam de forma independente e deixa o formato do JSON explícito no código.
- Qual é a diferença entre @Controller e @RestController?
- @RestController é @Controller mais @ResponseBody. Com um @Controller simples, um retorno do tipo String é interpretado como o nome de uma view a ser renderizada; com @RestController, todo valor de retorno é escrito no corpo da resposta por um HttpMessageConverter, o que para objetos significa JSON via Jackson. Use @RestController para APIs e @Controller para HTML renderizado no servidor.
- Que código de status um POST que cria um recurso deve devolver?
- 201 Created, com um header Location apontando para o novo recurso e, normalmente, a representação criada no corpo. Devolva 200 apenas quando o POST não cria nada endereçável (por exemplo, uma busca ou um comando). Para DELETE, o usual é 204 No Content quando nada é devolvido, e 404 se o recurso não for encontrado, a menos que você trate exclusões como idempotentes de propósito.
- O RestTemplate está depreciado?
- O RestTemplate está em modo de manutenção, não depreciado: continua funcionando e recebendo correções, mas as novidades vão para o RestClient (síncrono, fluente, introduzido no Spring Framework 6.1) e para o WebClient (reativo). Em um serviço novo com Spring Boot 3.2+, use o RestClient, ou um cliente HTTP interface apoiado nele, e migre o código com RestTemplate quando for mexer nele.