Spring Boot y Spring Cloud

Validación y gestión de errores en Spring Boot 3: Bean Validation y Problem Details

Intermedio7 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 rechazar la entrada incorrecta antes de que llegue a la lógica de negocio y a devolver errores con los que el cliente pueda actuar: aplicar restricciones de Jakarta Bean Validation a records y parámetros de método, escribir restricciones personalizadas, centralizar la gestión de excepciones en un @RestControllerAdvice y emitir respuestas application/problem+json estándar sin filtrar detalles internos.

Lo que aprenderás

  • Aplicar restricciones de Jakarta Bean Validation a los DTO de petición y a los parámetros del controlador, sabiendo qué excepción produce cada una
  • Escribir una restricción personalizada con su clase validadora y usar grupos de validación
  • Activar el soporte integrado de ProblemDetail en Spring y entender qué cubre
  • Centralizar los errores de dominio y de validación en un @RestControllerAdvice que genere Problem Details RFC 9457
  • Evitar filtrar stack traces, la estructura de las entidades y mensajes del framework en las respuestas de error

Una API se juzga tanto por cómo falla como por cómo funciona. El cliente necesita saber si ha enviado algo incorrecto, si el recurso existe, si reintentar servirá de algo y qué campo tiene que corregir. Spring Boot 3 te da las piezas para responder a esas preguntas de forma coherente: Jakarta Bean Validation para declarar cómo es una entrada válida, manejadores de excepciones para convertir los fallos en respuestas y el tipo ProblemDetail de Spring Framework 6 para un cuerpo de error estándar. Esta página las reúne en un único enfoque que puedes aplicar a todos los endpoints.

Declarar restricciones

Añade spring-boot-starter-validation, que incorpora Hibernate Validator, la implementación de referencia de Jakarta Bean Validation 3. La API está en jakarta.validation; si ves imports de javax.validation, estás leyendo material anterior a Boot 3.

Las restricciones son anotaciones sobre campos, componentes de record, parámetros y valores de retorno:

import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;

public record CreateCustomerRequest(
        @NotBlank @Size(max = 100) String name,
        @NotBlank @Email String email,
        @Pattern(regexp = "^\\+?[0-9 ]{6,20}$", message = "must be a phone number with digits and spaces")
        String phone,
        @NotNull @Past LocalDate dateOfBirth,
        @Valid @NotEmpty List<AddressRequest> addresses) {   // @Valid cascades into each element

    public record AddressRequest(
            @NotBlank String street,
            @NotBlank String city,
            @NotBlank @Size(min = 2, max = 2) String countryCode) {}
}

Las restricciones integradas más habituales son @NotNull, @NotBlank (no nulo y con al menos un carácter que no sea un espacio en blanco), @NotEmpty (no nulo y no vacío, para colecciones y cadenas), @Size, @Min/@Max, @Positive/@PositiveOrZero, @DecimalMin/@DecimalMax, @Digits, @Past/@Future (y sus variantes OrPresent), @Email y @Pattern. Hibernate Validator añade algunas más, como @URL y @Length, a costa de atarte a esa implementación.

Hay dos cosas que suelen sorprender. La primera: las restricciones no se propagan en cascada a menos que lo indiques; de una List<AddressRequest> solo se comprueba @NotEmpty hasta que añades @Valid. La segunda: @NotNull sobre un int primitivo no tiene sentido; un primitivo nunca es nulo, así que un campo JSON ausente se convierte silenciosamente en cero. Usa el tipo envoltorio Integer con @NotNull cuando la ausencia deba ser un error.

En las expresiones regulares de @Pattern, recuerda que debe coincidir el valor completo; puedes probar los patrones con entradas de ejemplo en el probador de regex antes de hacer commit.

Disparar la validación en los controladores

Spring MVC ejecuta la validación en tres puntos, y cada uno lanza una excepción distinta:

Dónde está la restricciónDisparadorExcepciónEstado por defecto
Objeto @RequestBody con @ValidResolución de argumentosMethodArgumentNotValidException400
@RequestParam, @PathVariable, @RequestHeader (Spring 6.1+)Validación de métodos integradaHandlerMethodValidationException400
Cualquier método de una clase anotada con @Validated (servicios, controladores antiguos)Proxy AOPConstraintViolationException500 si no se gestiona
@RestController
@RequestMapping("/api/customers")
class CustomerController {

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    CustomerResponse create(@Valid @RequestBody CreateCustomerRequest request) {
        return CustomerResponse.from(customers.register(request));
    }

    @GetMapping
    Page<CustomerResponse> search(@RequestParam @Size(min = 2) String q,
                                  @RequestParam(defaultValue = "20") @Max(100) int size) {
        return customers.search(q, size).map(CustomerResponse::from);
    }
}

Merece la pena entender la tercera fila. Antes de Spring Framework 6.1, las restricciones en parámetros simples se ignoraban salvo que el controlador llevara @Validated, que lo envolvía en un proxy; la ConstraintViolationException resultante no la gestionaba Spring MVC y acababa en un 500. Desde la 6.1, el propio framework valida esos parámetros cuando hay un proveedor disponible, y la HandlerMethodValidationException se trata como un 400. Si todavía anotas los controladores con @Validated, quítala para que no se ejecuten los dos mecanismos a la vez.

@Validated sigue siendo la herramienta para los servicios: anota la clase, pon las restricciones en los parámetros de los métodos, y quien pase datos incorrectos recibirá una ConstraintViolationException, venga la llamada de HTTP, de un scheduler o de un listener de mensajes.

Restricciones personalizadas y grupos

Cuando una regla es reutilizable y afecta solo al valor (un IBAN, un código de producto interno, una contraseña robusta), escribe una restricción en lugar de repetir comprobaciones en los servicios:

@Documented
@Constraint(validatedBy = SkuValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface Sku {
    String message() default "must be a SKU such as ABC-12345";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class SkuValidator implements ConstraintValidator<Sku, String> {
    private static final Pattern FORMAT = Pattern.compile("[A-Z]{3}-\\d{5}");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return value == null || FORMAT.matcher(value).matches();   // null is @NotNull's job
    }
}

Devuelve true para null, de modo que la opcionalidad siga siendo una decisión aparte que se toma con @NotNull. Los validadores son beans de Spring cuando se usa el LocalValidatorFactoryBean de Boot, así que pueden inyectar un repositorio para comprobar, por ejemplo, que un email no está ya registrado. Procura que esas comprobaciones sean baratas, porque se ejecutan cada vez que se valida el objeto, y resuelve de todos modos la condición de carrera en la base de datos con una restricción de unicidad.

Los grupos permiten que un mismo record tenga reglas distintas según la operación. Declara interfaces marcadoras (OnCreate, OnUpdate), asígnalas con @NotNull(groups = OnUpdate.class) y valida con @Validated(OnUpdate.class) en el parámetro del controlador. Los grupos son potentes y es fácil abusar de ellos; muchas veces queda más claro usar un record de petición distinto por operación.

Las restricciones a nivel de clase (anotación en el record, validador que recibe el objeto entero) expresan reglas que implican varios campos, como «la fecha de fin es posterior a la de inicio». Las reglas realmente de negocio, las que necesitan otros agregados o una transacción, van en el servicio, que lanza entonces una excepción de dominio.

Cuerpos de error estándar con ProblemDetail

La RFC 9457 (que deja obsoleta la RFC 7807) define un formato JSON para los errores que la mayoría de las herramientas de API ya entienden:

{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Request validation failed",
  "status": 400,
  "detail": "2 fields are invalid",
  "instance": "/api/customers",
  "errors": [
    { "field": "email", "message": "must be a well-formed email address" },
    { "field": "addresses[0].countryCode", "message": "size must be between 2 and 2" }
  ]
}

Spring Framework 6 lo modela como org.springframework.http.ProblemDetail, que se escribe con el media type application/problem+json. Boot pasa a este formato la gestión de excepciones propia del framework con una sola propiedad:

spring.mvc.problemdetails.enabled=true

Eso registra un ResponseEntityExceptionHandler que cubre las excepciones que lanza el propio Spring MVC: cuerpos ilegibles, media types no soportados, parámetros ausentes, MethodArgumentNotValidException, HandlerMethodValidationException, NoResourceFoundException y otras. No sabe nada de tus excepciones de dominio y su respuesta de validación no enumera los errores por campo, así que en la práctica lo extiendes.

Un único @RestControllerAdvice

Centraliza la correspondencia en una sola clase para que todos los controladores fallen igual:

@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    private static final URI VALIDATION_TYPE = URI.create("https://api.example.com/problems/validation-error");

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
        List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
                .map(fe -> Map.of("field", fe.getField(),
                                  "message", Objects.requireNonNullElse(fe.getDefaultMessage(), "invalid")))
                .toList();
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(status,
                errors.size() + " field(s) are invalid");
        problem.setType(VALIDATION_TYPE);
        problem.setTitle("Request validation failed");
        problem.setProperty("errors", errors);
        return handleExceptionInternal(ex, problem, headers, status, request);
    }

    @ExceptionHandler(CustomerNotFoundException.class)
    ProblemDetail notFound(CustomerNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,
                "No customer with id " + ex.customerId());
        problem.setTitle("Customer not found");
        return problem;
    }

    @ExceptionHandler(ObjectOptimisticLockingFailureException.class)
    ProblemDetail conflict(ObjectOptimisticLockingFailureException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
                "The resource was modified by another request; reload and retry");
        problem.setTitle("Concurrent modification");
        return problem;
    }

    @ExceptionHandler(Exception.class)
    ProblemDetail unexpected(Exception ex, HttpServletRequest request) {
        log.error("Unhandled exception for {} {}", request.getMethod(), request.getRequestURI(), ex);
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
        problem.setTitle("Unexpected error");
        problem.setDetail("The request could not be processed. Reference: " + MDC.get("traceId"));
        return problem;
    }
}

Basta con devolver un ProblemDetail desde un @ExceptionHandler para que Spring tome de él el código de estado y fije el content type a application/problem+json. setProperty añade miembros de extensión (errors en el ejemplo) que se serializan en el nivel raíz del JSON, tal como pretende la RFC. El manejador genérico registra en el log la excepción real con su stack trace y solo devuelve una referencia que el cliente puede facilitar a soporte; lo ideal es un identificador de traza o de correlación de tu sistema de trazas.

Las excepciones de dominio también pueden extender ErrorResponseException de Spring, que lleva un ProblemDetail y no necesita ningún manejador. Es cómodo en servicios pequeños; la clase advice escala mejor en cuanto quieres variar la respuesta según el tipo de excepción o añadir logging.

El orden importa cuando varios manejadores podrían aplicarse: Spring elige el tipo de excepción más cercano, así que CustomerNotFoundException gana a Exception. Si hay dos clases advice, @Order decide cuál se consulta primero.

El fallback /error de Boot (el BasicErrorController) sigue encargándose de todo lo que escapa de MVC, como los fallos dentro de un filtro. Su salida se controla con server.error.include-message, include-binding-errors, include-stacktrace e include-exception, y todas deberían quedarse en su valor por defecto never en producción.

Mensajes e internacionalización

Los mensajes de las restricciones se resuelven a través de un MessageSource. Ponlos en ValidationMessages.properties (la convención de Bean Validation, en la raíz del classpath) o, a partir de Boot 2.6, en el messages.properties de la aplicación, porque Boot conecta su MessageSource al validador:

customer.email.invalid=Enter a valid email address such as [email protected]

Después, haz referencia a él con @Email(message = "{customer.email.invalid}"). Los ficheros específicos de cada locale (messages_fr.properties) se eligen según la cabecera Accept-Language de la petición. Traducir o no los mensajes de error de una API es una decisión de producto; muchas APIs devuelven códigos legibles por máquina y dejan que el cliente se encargue de localizarlos.

Errores comunes

Olvidar @Valid en el cuerpo. Las restricciones están declaradas, el starter está presente y no pasa nada. La validación de un @RequestBody hay que activarla parámetro a parámetro.

Validar demasiado tarde. Las restricciones en las entidades (@NotBlank en un campo JPA) se ejecutan en el flush, dentro de la transacción, y aparecen como una TransactionSystemException que envuelve una ConstraintViolationException. Valida en la frontera de la API, sobre los records de petición, y trata las restricciones de entidad como una segunda línea de defensa.

Copiar ex.getMessage() en la respuesta. Los mensajes de DataIntegrityViolationException contienen SQL y nombres de restricciones; los de HttpMessageNotReadableException, nombres de clases y detalles internos de Jackson. Escribe tú el detalle.

Devolver un formato de error distinto en cada controlador. Dos manejadores, uno que produce { "error": ... } y otro { "message": ... }, obligan a cada cliente a tratar casos especiales. Una clase advice, un formato.

Lanzar ResponseStatusException por todas partes. Sirve para un 404 rápido, pero su mensaje se convierte en la reason de la respuesta, sus cuerpos no son coherentes con tus Problem Details y esparce conocimiento de HTTP por los servicios. Lanza excepciones de dominio y haz la correspondencia una sola vez.

Activar include-stacktrace=always «solo para staging». La configuración de staging tiene la costumbre de acabar siendo la de producción. Registra las trazas en el log; no las devuelvas nunca.

Puntos clave

  • Declara qué es válido con restricciones de Jakarta Bean Validation en los records de petición; @Valid en el @RequestBody las dispara y @Valid en los campos anidados propaga la validación en cascada.
  • Desde Spring Framework 6.1, las restricciones en @RequestParam y @PathVariable se aplican sin @Validated y producen HandlerMethodValidationException.
  • Las restricciones personalizadas cubren las reglas de formato reutilizables; las reglas de negocio que necesitan otros datos van en los servicios, como excepciones de dominio.
  • spring.mvc.problemdetails.enabled=true junto con un @RestControllerAdvice que extienda ResponseEntityExceptionHandler da a todos los fallos el mismo formato RFC 9457.
  • Añade los errors por campo como miembro de extensión, mantén el detail legible para una persona y no copies nunca mensajes de excepción en bruto ni stack traces en una respuesta.
  • Registra las excepciones inesperadas en el servidor con un identificador de correlación y devuelve ese identificador al cliente.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Qué diferencia hay entre @Valid y @Validated?
@Valid es la anotación de Jakarta Bean Validation: en un parámetro de controlador valida el objeto y propaga la validación en cascada a los objetos anidados marcados con @Valid. @Validated es la anotación de Spring: admite grupos de validación y, puesta en una clase, activa mediante un proxy la validación a nivel de método de parámetros y valores de retorno. Usa @Valid en los cuerpos de petición y @Validated cuando necesites grupos o quieras validar los argumentos de los métodos de un servicio.
¿Por qué no hacen nada las restricciones en @RequestParam o @PathVariable?
Antes de Spring Framework 6.1, las restricciones en parámetros simples solo se aplicaban si la clase del controlador estaba anotada con @Validated, lo que creaba un proxy y lanzaba ConstraintViolationException. Desde la 6.1, Spring MVC las valida de forma nativa siempre que haya un proveedor de Bean Validation, y lanza en su lugar HandlerMethodValidationException. Comprueba la versión del framework y asegúrate de que spring-boot-starter-validation está en el classpath.
¿Debo devolver 400 o 422 cuando falla la validación?
Los dos son defendibles. 400 Bad Request es lo que usa la gestión por defecto de Spring para MethodArgumentNotValidException y lo entiende cualquier cliente. 422 Unprocessable Content es más preciso cuando el JSON está bien formado pero incumple reglas de negocio. Elige uno, documéntalo en la API y aplícalo de forma coherente; no los mezcles para el mismo tipo de fallo.
¿Cómo evito que Spring Boot exponga mensajes de excepción y stack traces?
La respuesta de error por defecto de Boot ya omite el mensaje y la traza, salvo que server.error.include-message o server.error.include-stacktrace estén a always. Mantenlos en never en producción y haz que tu @RestControllerAdvice asocie cada excepción a un texto de detalle escrito a mano en lugar de copiar exception.getMessage(), que en los errores de persistencia y del framework suele contener SQL, nombres de clases o rutas de ficheros.