· 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.

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:

Herramienta/PasoEnlace de descarga
JavaDescargar Java
MavenDescargar Maven
IDE (IntelliJ, Eclipse, VS Code, etc.)Descargar IntelliJ
Proyecto con Spring Boot 3.5.xCrear proyecto Spring Boot

Dependencias

Las dependencias con las que se va trabajar son:

DependenciaDescripción
spring-boot-starter-webDependencias necesarias para construir aplicaciones web con servlet. Más en la documentación oficial.
spring-boot-starter-validationSoporte para la validación de datos utilizando la especificación Bean Validation. Más en la documentación oficial.
lombokBiblioteca que reduce el código repetitivo mediante anotaciones. Más en la documentación oficial.

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:

  1. Tomcat: La petición HTTP llega primero al servidor web, que por defecto es Tomcat.

  2. 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.

  3. DispatcherServlet: La petición se entrega al DispatcherServlet y consulta al HandlerMapping.

  4. 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}, el HandlerMapping sabe que debe dirigirla al método getCustomer(UUID id) en el CustomerController.
  5. HandlerAdapter: Una vez localizado el método, el HandlerAdapter se 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 id de la URL y lo convierte en un objeto UUID para pasarlo al método getCustomer(UUID id) en el CustomerController. Si fuera una petición POST, convierte el JSON a un objeto DTO anotado con @RequestBody.
  6. @RestController: Finalmente, la petición llega a tu método en el controlador. Aquí es donde se ejecuta el código que has escrito.

  7. 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:

  1. Establecer códigos de estado dinámicos: Si un recurso no se encuentra, se puede devolver un ResponseEntity.notFound().build(), que se traduce en un 404 Not Found. Puedes hacer una comprobación con un early return después de la respuesta. Evita usar un try/catch.

  2. Añadir cabeceras personalizadas: Después de crear un recurso, puede ser una buena práctica devolver un 201 Created junto con la cabecera Location que 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
Compartir: