· Jose Antonio López · 9 min lectura
Guía Completa de @RestController: Creando una API REST CRUD con Spring Boot
Aprende a crear APIs REST profesionales con Spring Boot. Esta guía paso a paso cubre @RestController para operaciones CRUD, aplicando las mejores prácticas de diseño y la gestión adecuada de estados HTTP.

Introducción
Esta guía describe mi enfoque recomendado para desarrollar controladores REST en Spring Boot 3.5.x. El objetivo es crear controladores limpios, mantenibles y que usen buenas prácticas. La información aquí sirve para la versión de Spring Boot que usa Servlets para crear aplicaciones web.
Las pautas que puedes encontrar en este artículo te ayudarán a estructurar tus controladores de manera efectiva, asegurando una clara separación de responsabilidades y facilitando el mantenimiento a largo plazo del código. Toda la información aquí está basada en mi experiencia profesional desarrollando APIs REST con Spring Boot.
¿Por qué escribo este artículo?
Durante mi carrera profesional, he visto que muchos desarrolladores tienen una concepción errónea sobre cómo desarrollar un controlador REST. A priori parece una tarea sencilla, aunque se cometen varios errores:
- Mala gestión del versionado desde el inicio.
- Lógica de negocio en los métodos del controlador.
- APIs que no verifican ni validan los datos de entrada.
Prerequisitos
Para seguir este artículo, necesitas tener instalado:
Dependencias
Las dependencias con las que se va trabajar son:
Decisiones de diseño
Inyección de dependencias mediante constructor
El tipo de inyección de dependencias que he usado es mediante constructor. Es la mejor forma de inyectar dependencias en Spring y la recomendada en la documentación oficial.
Si sueles usar inyección de dependencias mediante campos, te recomiendo que lo cambies a inyección de dependencias mediante constructor.
Lombok
Para reducir el código repetitivo, he usado la biblioteca Lombok que reduce el código visible mediante anotaciones. En este caso, se usan anotaciones @RequiredArgsConstructory @Log
Otras anotaciones que se pueden usar son @Data, @EqualsAndHashCode y @ToString, aunque considero que tocan temas muy delicados y recomiendo implementarlos a mano si llega el caso.
Identificador único para las entidades
Las entidades se identifican mediante un UUID válido. Para el ejemplo voy a usar UUID de versión 4, aunque en producción y nuevos sistemas aconsejo usar UUID v7, ya que da información sobre el tiempo de creación en la base de datos y ayuda a optimizar el indexado.
Sin paginación
Para simplificar, evito la paginación para devolver un listado. En un sistema en producción real no tiene por qué siempre haber un listado paginado.
Para start-ups o creación de nuevos prototipos, aconsejo no incluir siempre un listado, ya que los requerimientos fluctúan muchísimo y pueden suceder dos cosas:
- Cambio constante de código.
- La funcionalidad acaba por no usarse.
Versionados de la API
Versionar una API es muy importante porque permite evolucionar las funcionalidades expuestas al mundo exterior sin romper las existentes.
En Spring Boot existen varias estrategias para versionar la API. En mi caso, por practicidad, voy a usar una clase estática pura que estará abierta a la extensión y no a la modificación.
Puedes encontrar otro enfoque sobre el versionado de APIsen la documentación oficial de Spring Boot
Flujo de información en Spring usando Servlet
Cuando una petición HTTP llega a una aplicación Spring Boot que utiliza Servlets, sigue un flujo definido antes de llegar al controlador REST:
Imagina que una petición viaja a través de tu aplicación Spring Boot. Es como un paquete que pasa por varias estaciones hasta llegar a su destino:
Tomcat: La petición HTTP llega primero al servidor web, que por defecto es Tomcat.
Filtros: Antes de continuar, la petición atraviesa una cadena de filtros (
Filter). Un uso muy común es para la autenticación o autorización, donde un filtro comprueba si la petición incluye un token válido en las cabeceras.DispatcherServlet: La petición se entrega alDispatcherServlety consulta alHandlerMapping.HandlerMapping: Este componente enlaza la URL de la petición con un método específico del controlador.- Ejemplo: Si llega una petición
GET /v1/crm-mgmt-api/customers/{id}, elHandlerMappingsabe que debe dirigirla al métodogetCustomer(UUID id)en elCustomerController.
- Ejemplo: Si llega una petición
HandlerAdapter: Una vez localizado el método, elHandlerAdapterse prepara para llamarlo. Su trabajo es mapear los datos de la petición a los parámetros que el método Java espera recibir. En este punto es donde se aplican las validaciones de los DTOs (puedes ver cómo crear validadores personalizados aquí). Si algo falla, el sistema lanzará una excepción que debería ser capturada por un manejador global de excepciones.- Ejemplo: Extrae el
idde la URL y lo convierte en un objetoUUIDpara pasarlo al métodogetCustomer(UUID id)en elCustomerController. Si fuera una peticiónPOST, convierte el JSON a un objeto DTO anotado con@RequestBody.
- Ejemplo: Extrae el
@RestController: Finalmente, la petición llega a tu método en el controlador. Aquí es donde se ejecuta el código que has escrito.Serialización a JSON: El método devuelve un objeto Java (por ejemplo, un
CustomerDTO). Spring Boot usa una librería llamada Jackson por defecto y lo convierte automáticamente a formato JSON. Este JSON se envía de vuelta en el cuerpo de la respuesta HTTP junto con un código de estado HTTP.
Si quieres saber más sobre la especificación del Servlet, puedes consultar la especificación oficial Servlet 6.0, que es la que aplica a Spring Boot 3.5.
Versión de la API
El código para poder controlar el path de la API es el siguiente:
package com.test.crm.config;
public class ApiConfig {
public static final String API_VERSION_V1 = "/v1";
public static final String COMMON_PATH = "/crm-mgmt-api";
public static final String API_BASE_PATH_V1 = COMMON_PATH + API_VERSION_V1;
private ApiConfig() {
throw new UnsupportedOperationException("This class should never be instantiated");
}
}Explicación del código
El código define variables puramente estáticas y granulares para que no haya equivocación en qué versión está usando el endpoint.
En el contrato de una API es extraño encontrar versiones minor del tipo 1.1 o 1.2. Si existieran, no habría problema en añadir una nueva variable, pero ten en cuenta que debería añadir nueva funcionalidad o más datos.
Abierto a la extensión y cerrado a la modificación.
DTOs
El código es el siguiente:
package com.test.crm.dto;
public record CreateCustomerDTO(
@NotBlank
@Size(min = 3)
@Pattern(regexp = "^[^0-9]*$", message = "Only letters")
String name,
@NotBlank
@Size(min = 3)
@Pattern(regexp = "^[^0-9]*$", message = "Only letters")
String surname
) {
}public record CustomerDTO(
package com.test.crm.dto;
UUID id,
String name,
String surname,
String image,
String createdBy,
String lastUpdatedBy
) {
}public record UpdateCustomerDTO(
@NotBlank
@Size(min = 3)
@Pattern(regexp = "^[^0-9]*$", message = "Only letters")
String name,
@NotBlank
@Size(min = 3)
@Pattern(regexp = "^[^0-9]*$", message = "Only letters")
String surname
) {
}Controlador
El código es el siguiente:
package com.test.crm.controller;
@RestController
@RequestMapping(API_BASE_PATH + "/customers")
@RequiredArgsConstructor
public class CustomerController {
private final CustomerService customerService;
@GetMapping("/{id}")
public CustomerDTO getCustomer(final @PathVariable UUID id) {
return customerService.getCustomer(id);
}
@GetMapping
public List<CustomerDTO> getCustomers() {
return customerService.getCustomers();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public CustomerDTO createCustomer(final @RequestBody @Valid CreateCustomerDTO createCustomerDTO) {
return customerService.createCustomer(createCustomerDTO);
}
@PatchMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void updateCustomer(final @PathVariable UUID id, final @RequestBody @Valid UpdateCustomerDTO updateCustomerDTO) {
customerService.updateCustomer(id, updateCustomerDTO);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteCustomer(final @PathVariable UUID id) {
customerService.deleteCustomer(id);
}
}Explicación del código
¿@PatchMapping o @PutMapping?
Para una sustitución parcial o actualización de los datos, hay que usar el método PATCH. Si es una sustitución completa, entonces hay que usar el método PUT.
Esta sutil diferencia normalmente se pasa por alto en muchos sistemas actuales a la hora de diseñar APIs, así que es bueno tenerlo en cuenta.
DeleteMapping
En el ejemplo, el borrado de la entidad se hace de forma física; es decir, los datos se borran de la base de datos. Existe otro tipo de borrado llamado borrado lógico. Consiste en actualizar una bandera o flag de un dato boolean del tipo isActive.
Para el borrado lógico, prefiero usar el método PATCH.
ResponseEntity
Muchos desarrolladores usan la entidad ResponseEntity para envolver los objetos de respuesta en un @RestController. Sin embargo, esto a menudo es innecesario gracias a la autoconfiguración de Spring Boot.
Cuando un método de un controlador devuelve un objeto, Spring Boot usa Jackson (por defecto) para serializarlo automáticamente a formato JSON. Además, establece la cabecera Content-Type en application/json y devuelve un código de estado 200 OK.
Como se puede ver en los métodos getCustomer, getCustomers y createCustomer, se devuelve directamente el DTO (CustomerDTO o List<CustomerDTO>). Spring se encarga del resto.
¿Cuándo deberías usar ResponseEntity?
ResponseEntity es muy útil cuando necesitas un control más fino sobre la respuesta HTTP. Por ejemplo:
Establecer códigos de estado dinámicos: Si un recurso no se encuentra, se puede devolver un
ResponseEntity.notFound().build(), que se traduce en un404 Not Found. Puedes hacer una comprobación con un early return después de la respuesta. Evita usar un try/catch.Añadir cabeceras personalizadas: Después de crear un recurso, puede ser una buena práctica devolver un
201 Createdjunto con la cabeceraLocationque contiene la URL del nuevo recurso.
@PostMapping
public ResponseEntity<CustomerDTO> createCustomer(final @RequestBody @Valid CreateCustomerDTO createCustomerDTO) {
final CustomerDTO createdCustomer = customerService.createCustomer(createCustomerDTO);
final URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(createdCustomer.getId())
.toUri();
return ResponseEntity.created(location).body(createdCustomer);
}¿Por qué no usar ResponseEntity<?>?
Evita el uso de tipos genéricos como ResponseEntity<?>. Esta práctica oculta el tipo de dato real que devuelve tu API y dificulta su consumo y la generación de documentación automática (como con OpenAPI/Swagger). Siempre especifica el tipo de dato con ResponseEntity<CustomerDTO>.
@GetMapping("/{id}")
public ResponseEntity<CustomerDTO> getCustomer(final @PathVariable UUID id) {
return ResponseEntity.ok(customerService.getCustomer(id));
}Test
Para poder testear el controlador puedes usar la siguiente estructura recomendada:
package com.test.crm.controller;
@WebMvcTest(CustomerController.class)
@AutoConfigureMockMvc
class CustomerControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private CustomerService customerService;
private final JsonMapper mapper = JsonMapper.builder().build();
@Test
void get_customer_ok() throws Exception {
final CustomerDTO customerDTO = new CustomerDTO(UUID.randomUUID(), "John", "Doe", "", "", "");
doReturn(customerDTO).when(customerService).getCustomer(any(UUID.class));
mockMvc.perform(MockMvcRequestBuilders.get(ApiConfig.API_BASE_PATH_V1 + "/customers/" + customerDTO.id())
.contentType(MediaType.APPLICATION_JSON))
.andExpectAll(
MockMvcResultMatchers.status().isOk(),
MockMvcResultMatchers.jsonPath("$.id").value(customerDTO.id().toString()),
MockMvcResultMatchers.jsonPath("$.name").value(customerDTO.name()),
MockMvcResultMatchers.jsonPath("$.surname").value(customerDTO.surname())
);
}
@Test
void get_customers_ok() throws Exception {
final CustomerDTO customerDTO1 = new CustomerDTO(UUID.randomUUID(), "John", "Doe", "", "", "");
final CustomerDTO customerDTO2 = new CustomerDTO(UUID.randomUUID(), "Jane", "Smith", "", "", "");
doReturn(List.of(customerDTO1, customerDTO2)).when(customerService).getCustomers();
final MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.get(ApiConfig.API_BASE_PATH_V1 + "/customers")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isOk())
.andReturn();
final List<CustomerDTO> customerDTOS = mapper.readValue(mvcResult.getResponse().getContentAsString(), new TypeReference<>() {
});
assertThat(customerDTOS).containsExactlyInAnyOrder(customerDTO1, customerDTO2);
}
@Test
void create_customer_ok() throws Exception {
final CreateCustomerDTO createCustomerDTO = new CreateCustomerDTO("John", "Doe");
final CustomerDTO customerDTO = new CustomerDTO(UUID.randomUUID(), "John", "Doe", "", "", "");
doReturn(customerDTO).when(customerService).createCustomer(any(CreateCustomerDTO.class));
mockMvc.perform(MockMvcRequestBuilders.post(ApiConfig.API_BASE_PATH_V1 + "/customers")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(createCustomerDTO)))
.andExpectAll(
MockMvcResultMatchers.status().isCreated(),
MockMvcResultMatchers.jsonPath("$.id").value(customerDTO.id().toString()),
MockMvcResultMatchers.jsonPath("$.name").value(customerDTO.name()),
MockMvcResultMatchers.jsonPath("$.surname").value(customerDTO.surname())
);
}
@Test
void update_customer_ok() throws Exception {
final UUID customerId = UUID.randomUUID();
final UpdateCustomerDTO updateCustomerDTO = new UpdateCustomerDTO("John", "Doe");
mockMvc.perform(MockMvcRequestBuilders.patch(ApiConfig.API_BASE_PATH_V1 + "/customers/" + customerId)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(updateCustomerDTO)))
.andExpect(MockMvcResultMatchers.status().isNoContent());
}
@Test
void delete_customer_ok() throws Exception {
final UUID customerId = UUID.randomUUID();
mockMvc.perform(MockMvcRequestBuilders.delete(ApiConfig.API_BASE_PATH_V1 + "/customers/" + customerId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isNoContent());
}
}Anotaciones
@WebMvcTest(CustomerController.class) se usa para cargar solo el contexto web necesario para probar el CustomerController. Las pruebas son más rápidas y aisladas.
@AutoConfigureMockMvc permite la inyección de MockMvc, que se utiliza para simular peticiones HTTP al controlador sin necesidad de iniciar un servidor real.
@MockBean crea un mock del CustomerService que se inyecta en el controlador. Esto permite controlar el comportamiento del servicio durante las pruebas.
- Java
- Spring Boot