El Arte de la Consistencia: Creando Respuestas API Predecibles en Spring MVC
- Mauricio ECR
- Snippets
- 24 Sep, 2025
El Arte de la Consistencia: Creando Respuestas API Predecibles en Spring MVC Imagina a un desarrollador frontend consumiendo tu API. En un endpoint, recibe un objeto JSON. En otro, una simple lista. Si ocurre un error de validación, obtiene una estructura compleja; si el servidor falla, recibe un texto plano. Cada variación, por pequeña que sea, introduce una nueva lógica condicional en el cliente. Rápidamente, esa falta de estándar se convierte en un caos silencioso, una deuda técnica que frena la innovación y fragiliza el sistema.
La estandarización de las respuestas de una API no es una cuestión de estética, sino una decisión de arquitectura fundamental. El verdadero desafío es cómo lograr esta uniformidad sin contaminar nuestra lógica de negocio con código repetitivo. Afortunadamente, Spring MVC nos ofrece herramientas de una elegancia sorprendente, @RestControllerAdvice y ResponseBodyAdvice, diseñadas precisamente para resolver estos problemas transversales de forma limpia y centralizada.
Este artículo te guiará en la implementación de un patrón de respuesta robusto y unificado en un entorno Spring Boot con Lombok, cubriendo tanto los casos de éxito como los de error de manera consistente y profesional.
El Contrato: La Piedra Angular de la Previsibilidad
Antes de escribir una sola línea de lógica, debemos definir nuestro objetivo: un formato de respuesta único que sirva tanto para éxitos como para errores. Esta es la base de la predictibilidad. En lugar de improvisar, diseñaremos una estructura genérica que actúe como un contrato inmutable con nuestros clientes.
La clave de nuestra estrategia es la clase ApiResponse. Este DTO (Data Transfer Object) genérico contendrá tres componentes principales:
meta: Un objeto con metadatos de la solicitud (timestamp, ID de la petición, etc.), útil para la depuración y el monitoreo.data: El payload real de la respuesta en caso de éxito. Será de tipo genérico (T).errors: Una lista de errores detallados si algo sale mal.
Una de las decisiones más importantes aquí es el uso de la anotación @JsonInclude(JsonInclude.Include.NON_NULL). Esta simple línea le indica a Jackson (el serializador JSON de Spring) que omita cualquier campo con valor nulo. ¿El resultado? Las respuestas exitosas no tendrán el campo errors y las de error no tendrán el campo data, manteniendo así los JSON limpios y relevantes sin necesidad de crear múltiples clases.
Para construir este contrato, asegúrate de tener las dependencias esenciales en tu pom.xml:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
Y aquí está el diseño de nuestro contrato unificado:
// src/main/java/com/example/demo/common/ApiResponse.java
package com.example.demo.common;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
private Meta meta;
private T data;
private List<ErrorDetail> errors;
@Data
@Builder
public static class Meta {
private String timestamp = Instant.now().toString();
@Builder.Default
private String requestId = UUID.randomUUID().toString().substring(0, 10);
private String path;
private int status;
}
@Data
@Builder
public static class ErrorDetail {
private String code;
private String message;
}
public static <T> ApiResponse<T> success(T data, String path, int status) {
return ApiResponse.<T>builder()
.meta(Meta.builder().path(path).status(status).build())
.data(data)
.build();
}
public static ApiResponse<?> error(List<ErrorDetail> errors, String path, int status) {
return ApiResponse.builder()
.meta(Meta.builder().path(path).status(status).build())
.errors(errors)
.build();
}
}
La Arquitectura de la Consistencia: Separando Responsabilidades
Para una solución robusta y mantenible, aplicaremos el Principio de Responsabilidad Única. En lugar de una sola clase monolítica, dividiremos nuestra lógica en dos componentes especializados, ambos anotados con @RestControllerAdvice. Spring es lo suficientemente inteligente como para detectar y aplicar ambos.
Primero, crearemos una configuración para permitirnos habilitar, deshabilitar o excluir rutas de este comportamiento, dándonos flexibilidad para casos especiales como los endpoints de Actuator o Swagger.
// src/main/java/com/example/demo/common/ResponseWrapperProperties.java
package com.example.demo.common;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Data
@Component
@ConfigurationProperties(prefix = "api.response.wrapper")
public class ResponseWrapperProperties {
private boolean enabled = true;
private List<String> excludedPaths = new ArrayList<>();
}
1. El Guardián de Respuestas Exitosas
Nuestra primera clase, GlobalResponseHandler, tendrá una sola misión: interceptar las respuestas exitosas de los controladores y envolverlas en nuestra estructura ApiResponse. Utiliza la interfaz ResponseBodyAdvice para modificar el cuerpo de la respuesta justo antes de que se envíe.
// src/main/java/com/example/demo/common/GlobalResponseHandler.java
package com.example.demo.common;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalResponseHandler implements ResponseBodyAdvice<Object> {
private final ResponseWrapperProperties properties;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
String path = servletRequest.getRequestURI();
// Si el cuerpo ya es un ApiResponse (creado por el manejador de excepciones)
// o la ruta está excluida, no hacemos nada.
if (body instanceof ApiResponse || isExcluded(path)) {
return body;
}
int status = ((ServletServerHttpResponse) response).getServletResponse().getStatus();
return ApiResponse.success(body, path, status);
}
private boolean isExcluded(String path) {
return !properties.isEnabled() || properties.getExcludedPaths().stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path));
}
}
2. El Centinela Central de Errores
La segunda clase, GlobalExceptionHandler, se dedicará exclusivamente a capturar excepciones lanzadas desde cualquier controlador. Usando @ExceptionHandler, las convierte en nuestra respuesta ApiResponse estandarizada. Este aislamiento hace que el código de manejo de errores sea fácil de encontrar, mantener y extender.
// src/main/java/com/example/demo/common/GlobalExceptionHandler.java
package com.example.demo.common;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<?> handleValidationExceptions(MethodArgumentNotValidException ex, HttpServletRequest request) {
List<ApiResponse.ErrorDetail> errors = ex.getBindingResult().getFieldErrors().stream()
.map(error -> ApiResponse.ErrorDetail.builder()
.code("VALIDATION_ERROR")
.message(String.format("'%s': %s", error.getField(), error.getDefaultMessage()))
.build())
.collect(Collectors.toList());
return ApiResponse.error(errors, request.getRequestURI(), HttpStatus.BAD_REQUEST.value());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<?> handleAllUncaughtException(Exception ex, HttpServletRequest request) {
log.error("Error no controlado en la ruta {}: {}", request.getRequestURI(), ex.getMessage(), ex);
ApiResponse.ErrorDetail error = ApiResponse.ErrorDetail.builder()
.code("INTERNAL_SERVER_ERROR")
.message("Ocurrió un error inesperado. Por favor, contacte al soporte.")
.build();
return ApiResponse.error(Collections.singletonList(error), request.getRequestURI(), HttpStatus.INTERNAL_SERVER_ERROR.value());
}
}
Ganando Confianza: Pruebas que Validan la Arquitectura
Probar componentes transversales es crucial. Con @WebMvcTest, creamos un contexto de prueba ligero que se enfoca en la capa web. La clave es importar ambas clases de Advice en nuestro test para asegurar que el comportamiento combinado (formateo de éxito y manejo de errores) se verifica correctamente.
// src/test/java/com/example/demo/common/GlobalResponseHandlerTest.java
package com.example.demo.common;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(controllers = GlobalResponseHandlerTest.TestController.class)
// Importamos AMBAS clases para que el contexto de prueba refleje la configuración real.
@Import({GlobalResponseHandler.class, GlobalExceptionHandler.class})
class GlobalResponseHandlerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ResponseWrapperProperties responseWrapperProperties;
// ... (El resto de la clase de prueba, incluyendo setUp, TestController, TestDto y los métodos de prueba, permanece igual) ...
@BeforeEach
void setUp() {
when(responseWrapperProperties.isEnabled()).thenReturn(true);
when(responseWrapperProperties.getExcludedPaths()).thenReturn(List.of("/excluded/**"));
}
@RestController
static class TestController {
@GetMapping("/test/success")
public TestDto getSuccess() { return new TestDto("ok"); }
@PostMapping("/test/validation")
public TestDto postValidation(@Valid @RequestBody TestDto dto) { return dto; }
@GetMapping("/excluded/path")
public TestDto getExcluded() { return new TestDto("excluded"); }
}
@Data
@AllArgsConstructor
static class TestDto {
@NotEmpty
private String message;
}
@Test
@DisplayName("Debería envolver una respuesta exitosa en el formato ApiResponse")
void shouldWrapSuccessResponse() throws Exception {
mockMvc.perform(get("/test/success"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta").exists())
.andExpect(jsonPath("$.data.message").value("ok"))
.andExpect(jsonPath("$.errors").doesNotExist());
}
@Test
@DisplayName("No debería envolver una respuesta si la ruta está excluida")
void shouldNotWrapExcludedPath() throws Exception {
mockMvc.perform(get("/excluded/path"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta").doesNotExist())
.andExpect(jsonPath("$.message").value("excluded"));
}
@Test
@DisplayName("Debería manejar un error de validación y devolver ApiResponse con detalles de error")
void shouldHandleValidationError() throws Exception {
String invalidDtoJson = "{\"message\":\"\"}";
mockMvc.perform(post("/test/validation")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidDtoJson))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.meta").exists())
.andExpect(jsonPath("$.data").doesNotExist())
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors[0].code").value("VALIDATION_ERROR"));
}
}
Más Allá del Código: El Impacto de una Arquitectura Consistente
Hemos recorrido un camino que va más allá de un simple truco de código. Partimos de un problema real —el caos de las respuestas inconsistentes— y, en lugar de aplicar parches, diseñamos una solución arquitectónica limpia basada en la separación de responsabilidades.
El resultado es un patrón robusto y no invasivo que unifica todas las respuestas bajo un contrato predecible. La lógica está aislada, es configurable y completamente testeable. Esta inversión en diseño reduce la carga cognitiva para todos, desde los desarrolladores del backend hasta los consumidores de la API, creando sistemas más mantenibles, escalables y, en definitiva, más sencillos de razonar.
Este patrón no es un punto final, sino una base sólida. Las posibilidades futuras son claras:
- Documentación de API: El siguiente paso es asegurar que herramientas como OpenAPI/Swagger reflejen esta estructura
ApiResponseautomáticamente, proporcionando una documentación precisa del contrato real. - Trazabilidad Distribuida: El
requestIden los metadatos es la semilla para una trazabilidad completa. Integrarlo con herramientas como Micrometer Tracing permitiría seguir una petición a través de múltiples microservicios, simplificando la depuración en entornos complejos. - Observabilidad Mejorada: El bloque
metapuede enriquecerse con más datos, como el tiempo de procesamiento, para alimentar dashboards en herramientas como Grafana y Prometheus, ofreciendo una visión más profunda del rendimiento de la API.