Spring Boot y Spring Cloud
API REST con Spring Boot 3: controladores, DTO, códigos de estado y clientes
Intermedio9 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Aprenderás a construir una API JSON limpia con Spring Boot 3: asociar recursos a controladores, usar records como cuerpos de petición y respuesta, devolver los códigos de estado y las cabeceras correctos, gestionar la paginación y la negociación de contenido, configurar CORS de forma deliberada y llamar a otros servicios con el RestClient que introdujo Spring Framework 6.1.
Lo que aprenderás
- Estructurar una API orientada a recursos con @RestController y las anotaciones de mapeo compuestas
- Usar records de Java como DTO de petición y respuesta y mantener las entidades JPA fuera del contrato de la API
- Devolver los códigos de estado HTTP, las cabeceras Location y los cuerpos ResponseEntity correctos al crear, actualizar y eliminar
- Admitir paginación, ordenación y negociación de contenido sin filtrar detalles de persistencia
- Llamar a servicios HTTP externos con RestClient y con clientes declarativos HTTP interface
La mayoría de los servicios Spring Boot existen para exponer una API HTTP a la que llaman otros programas. Spring MVC hace que la mecánica sea sencilla, y precisamente por eso tantas API acaban siendo incoherentes: un endpoint devuelve 200 al crear, otro devuelve entidades que lanzan excepciones de carga lazy y un tercero acepta un tamaño de página de un millón. Esta página explica cómo construir una API que se comporte como una bien diseñada, desde la capa de controladores hasta los DTO, los códigos de estado y la paginación, y termina en el lado del cliente, porque un servicio dentro de un sistema de servicios también es consumidor. Se da por hecho Spring Boot 3.2 o posterior, para poder usar RestClient.
El ciclo de vida de una petición en un párrafo
Una petición llega al Tomcat embebido, atraviesa la cadena de filtros de servlet (seguridad, CORS, trazas) y aterriza en el DispatcherServlet. El dispatcher pregunta a sus handler mappings qué método de controlador coincide con la ruta y el método HTTP, convierte las variables de ruta, los parámetros de consulta y el cuerpo en argumentos del método, lo invoca y pasa el valor de retorno a un conversor de mensajes que escribe JSON (Jackson por defecto). Las excepciones suben hasta los manejadores de excepciones, que generan una respuesta de error. Todo lo que configuras está en alguno de esos puntos, así que saber dónde vive una funcionalidad (filtro, argument resolver, conversor de mensajes, manejador de excepciones) te dice cómo personalizarla.
Controladores orientados a recursos
Una API REST modela recursos identificados por URL y manipulados con métodos HTTP. En Spring, eso se traduce en un controlador por agregado, con la ruta a nivel de clase y los verbos a nivel de método:
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);
}
}
Varias de estas decisiones son deliberadas. La inyección por constructor permite probar el controlador sin contenedor. El controlador no tiene lógica de negocio: traduce HTTP en llamadas al servicio y objetos de dominio en respuestas. @ResponseStatus se usa cuando el estado es fijo, y ResponseEntity cuando el estado o las cabeceras dependen del resultado. La ruta usa sustantivos y un subrecurso anidado (/shipping-address) en lugar de un verbo como /updateAddress.
Spring MVC resuelve las rutas con PathPatternParser desde Boot 2.6, y Spring Framework 6 dejó de aceptar por defecto la barra final: /api/orders/ ya no se enruta a /api/orders. Si hay clientes que dependen del comportamiento anterior, añade una redirección o configura UrlHandlerFilter en lugar de restaurar la opción global.
Vincular los datos de la petición
| Anotación | Vincula | Uso típico |
|---|---|---|
@PathVariable | Un segmento de la plantilla de URI | Identificadores de recursos |
@RequestParam | Un parámetro de consulta o de formulario | Filtros, paginación, indicadores; required = false y defaultValue para los opcionales |
@RequestBody | El cuerpo completo mediante un conversor de mensajes | Payloads JSON |
@RequestHeader | Una cabecera | Identificadores de correlación, peticiones condicionales |
@ModelAttribute (implícito) | Muchos parámetros de consulta en un solo objeto | Formularios de búsqueda con varios campos opcionales |
Desde Spring Framework 6.1, los nombres de los parámetros ya no están disponibles por reflexión salvo que el código se compile con -parameters; el POM padre de Boot y el plugin de Gradle activan ese flag, pero un build personalizado tiene que añadirlo o cada @PathVariable necesitará un nombre explícito.
Records como DTO
Usa records en ambos sentidos. Son inmutables, concisos, y Jackson (incluido en el starter web) los serializa y deserializa sin anotaciones:
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());
}
}
Mantener las entidades JPA fuera del contrato evita tres problemas recurrentes: colecciones lazy que estallan con LazyInitializationException durante la serialización, asociaciones bidireccionales que entran en recursión infinita y la exposición accidental de columnas que nadie pretendía publicar. Los métodos de mapeo son tediosos de escribir pero fáciles de leer; MapStruct los genera si su número crece.
Algunos ajustes de Jackson que casi cualquier API necesita se configuran en propiedades en lugar de con anotaciones:
spring.jackson.default-property-inclusion=non_null
spring.jackson.serialization.write-dates-as-timestamps=false
spring.jackson.deserialization.fail-on-unknown-properties=false
Con el segundo ajuste, los tipos de java.time se serializan como cadenas ISO-8601; el primero elimina los nulos de las respuestas; el tercero es el valor por defecto de Boot y mantiene funcionando a los clientes antiguos cuando añades campos. Si necesitas prohibir campos desconocidos en un tipo de petición concreto, anota ese record con @JsonIgnoreProperties(ignoreUnknown = false).
Códigos de estado y cabeceras con significado
La semántica HTTP (RFC 9110) es el contrato que tus clientes ya entienden, así que úsala en lugar de inventar un envoltorio con "success": true:
| Situación | Estado | Notas |
|---|---|---|
| Lectura correcta | 200 OK | El cuerpo es la representación |
| Recurso creado | 201 Created | Cabecera Location; cuerpo opcional pero útil |
| Aceptado para procesamiento asíncrono | 202 Accepted | El cuerpo o una cabecera indican al cliente dónde consultar el estado |
| Actualización o eliminación sin nada que devolver | 204 No Content | Sin cuerpo |
| Entrada mal formada o inválida | 400 Bad Request | O 422 cuando la sintaxis es correcta pero la semántica no |
| No autenticado / prohibido | 401 / 403 | Los genera Spring Security |
| Recurso desconocido | 404 Not Found | También para identificadores válidos que el llamante no puede ver, si ocultas su existencia |
| Conflicto de versión, duplicado | 409 Conflict | Bloqueo optimista, restricciones de unicidad |
| Fallo inesperado | 500 Internal Server Error | Nunca expongas stack traces |
ResponseEntity es la herramienta para todo lo que vaya más allá de un estado fijo:
@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 el lado de los errores (traducir excepciones en respuestas ProblemDetail), consulta la guía de validación y gestión de errores; lo importante aquí es que el camino feliz ya tiene que ser preciso. La referencia de códigos de estado HTTP de la sección Web enumera cada código y explica cuándo usarlo.
Paginación, ordenación y filtrado
Las colecciones sin límite son el fallo de rendimiento más habitual en las API. El Pageable de Spring Data se puede inyectar directamente en un método de controlador cuando el soporte web de Spring Data está en el classpath, como ocurre con 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);
}
Los clientes envían ?page=0&size=20&sort=createdAt,desc. Limita el tamaño (spring.data.web.pageable.max-page-size, 2000 por defecto, suele ser demasiado generoso para una API) y plantéate devolver una forma de página estable y mínima. Desde Spring Data 3.3, serializar una Page directamente registra una advertencia en el log, porque su forma JSON no se considera un contrato estable; configura spring.data.web.pageable.serialization-mode=via_dto para obtener la estructura más sencilla de PagedModel, o envuelve la página en tu propio record para que el contrato no cambie cuando cambie una librería. La paginación por offset sirve para pantallas de administración; para feeds y tablas grandes, es preferible la paginación por clave (keyset, ?after=<last id>), que sigue siendo rápida sea cual sea la profundidad.
El filtrado va en los parámetros de consulta (?status=SHIPPED&customerId=42), nunca en el cuerpo de un GET. Si una búsqueda es tan compleja que necesita un cuerpo, modélala como POST /api/orders/search y documenta que es segura e idempotente.
Negociación de contenido y versionado
Spring MVC elige el formato de la respuesta a partir de la cabecera Accept. Si solo está Jackson, la respuesta siempre es JSON; añade jackson-dataformat-xml y el mismo controlador servirá XML al cliente que lo pida. Activar el parámetro de consulta format (spring.mvc.contentnegotiation.favor-parameter=true) ayuda cuando los navegadores o los proxies alteran las cabeceras.
El versionado no tiene una única respuesta correcta. Los prefijos de URL (/api/v2/orders) son los más fáciles de enrutar, cachear y documentar; un tipo de medio personalizado (Accept: application/vnd.shop.v2+json) mantiene las URL estables a costa de que sean más difíciles de descubrir; una cabecera (X-API-Version: 2) es sencilla pero invisible para las cachés. Elijas lo que elijas, crea una versión nueva solo ante cambios incompatibles y haz los cambios aditivos (nuevos campos opcionales) sin cambiar de versión.
CORS
Los navegadores rechazan las llamadas JavaScript entre orígenes distintos salvo que el servidor las permita expresamente. Una aplicación de página única servida desde https://shop.example.com que llama a https://api.example.com necesita una 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 en un controlador hace lo mismo por clase o por método. Si Spring Security forma parte de la cadena, CORS también debe activarse ahí (http.cors(Customizer.withDefaults())) para que la petición OPTIONS de preflight no se rechace antes de llegar a MVC. Los navegadores rechazan los orígenes comodín combinados con credenciales, y un comodín por sí solo significa que cualquier web puede llamar a tu API desde el navegador de un usuario con sesión iniciada; enumera los orígenes a los que realmente das servicio.
Llamar a otros servicios
Spring Framework 6.1 añadió RestClient, un cliente síncrono con el estilo fluido de WebClient y sin dependencia reactiva. Boot 3.2 autoconfigura un RestClient.Builder que ya incluye los conversores de mensajes y cualquier bean RestClientCustomizer (por ejemplo, las cabeceras de trazas de Micrometer), así que inyecta el builder en lugar de llamar a 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 un cliente con más operaciones, declara una HTTP interface y deja que Spring genere la implementación:
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);
}
Uses el cliente que uses, configura timeouts de conexión y de lectura (spring.http.client.connect-timeout y read-timeout en Boot 3.4+, o a través de la request factory en versiones anteriores). Un cliente sin timeout de lectura convierte una dependencia lenta en agotamiento de threads en tu propio servicio.
Errores habituales
Verbos en las URL y todo por POST. /api/getOrder?id=1 renuncia a la caché, a la semántica de los métodos y a que las herramientas entiendan la API. Asocia las lecturas a GET, la creación a POST, el reemplazo completo a PUT, las actualizaciones parciales a PATCH y la eliminación a DELETE.
Devolver 200 para todo, con un indicador de error en el cuerpo. Los clientes, los proxies, la monitorización y la lógica de reintentos se basan en el código de estado. Un 200 que significa «ha fallado» es invisible para todos ellos.
Exponer entidades y confiar en @JsonIgnore para ocultar campos. Funciona hasta que alguien añade un campo, y acopla el esquema a la API. Mapea a records.
Open Session in View ocultando la carga lazy. Boot activa spring.jpa.open-in-view por defecto y emite una advertencia al respecto. Mantiene abierto el contexto de persistencia durante la serialización JSON, de modo que las asociaciones lazy se cargan consulta a consulta mientras se escribe la respuesta. Ponlo a false y carga lo que necesite el DTO en el servicio o el repositorio.
Tamaños de página sin límite y timeouts ausentes. Ambos van bien en desarrollo y son catastróficos bajo carga; fija límites desde el primer día.
Ignorar -parameters. Un build personalizado de Maven o Gradle sin ese flag hace que cada @PathVariable long id falle en tiempo de ejecución en Spring Framework 6.1+, con un mensaje sobre nombres de parámetros ausentes.
Ideas clave
- Modela los recursos como controladores con rutas a nivel de clase y verbos a nivel de método; mantén los controladores ligeros y delega en los servicios.
- Usa records como DTO de petición y respuesta y nunca serialices entidades JPA directamente.
- Devuelve los códigos de estado y las cabeceras que HTTP ya define: 201 con
Locational crear, 204 para un éxito sin contenido, 4xx para los errores del cliente. - Limita cada endpoint de colección con paginación y expón una forma de página estable; prefiere la paginación por clave para listas profundas o con mucho tráfico.
- Activa CORS explícitamente para los orígenes a los que das servicio, tanto en MVC como en Spring Security.
- Llama a otros servicios con
RestCliento con una HTTP interface creada a partir del builder autoconfigurado por Boot, y configura siempre timeouts.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Debo devolver entidades JPA directamente desde un controlador?
- Mejor evitarlo. Las entidades arrastran asociaciones lazy que se serializan mal o disparan consultas adicionales, convierten la estructura de la base de datos en contrato de la API, y cualquier campo que añadas por motivos de persistencia pasa a ser público sin que nadie se dé cuenta. Un DTO basado en records por endpoint permite que la API y el esquema evolucionen por separado y deja explícita en el código la forma del JSON.
- ¿Qué diferencia hay entre @Controller y @RestController?
- @RestController es @Controller más @ResponseBody. Con un @Controller a secas, un valor de retorno String se interpreta como el nombre de una vista que hay que renderizar; con @RestController, cada valor de retorno se escribe en el cuerpo de la respuesta mediante un HttpMessageConverter, lo que para los objetos significa JSON a través de Jackson. Usa @RestController para las API y @Controller para HTML renderizado en el servidor.
- ¿Qué código de estado debe devolver un POST que crea un recurso?
- 201 Created, con una cabecera Location que apunte al nuevo recurso y, normalmente, la representación creada en el cuerpo. Devuelve 200 solo cuando el POST no crea nada direccionable (por ejemplo, una búsqueda o un comando). Para DELETE, lo habitual es 204 No Content cuando no se devuelve nada, y 404 si el recurso no existe, salvo que decidas tratar las eliminaciones como idempotentes a propósito.
- ¿Está RestTemplate obsoleto?
- RestTemplate está en modo mantenimiento, no obsoleto: sigue funcionando y recibe correcciones, pero las novedades van a RestClient (síncrono, fluido, introducido en Spring Framework 6.1) y a WebClient (reactivo). Para un servicio nuevo con Spring Boot 3.2+ usa RestClient, o un cliente HTTP interface basado en él, y migra el código con RestTemplate cuando tengas que tocarlo.