Validación en Spring Boot con Bean Validation (@Valid, @NotBlank, …)
1. Descripción general
Cuando construyes una REST API, siempre necesitas validar los datos de entrada antes de procesarlos: email con formato correcto, nombre no vacío, edad debe ser positiva… Bean Validation (JSR 380, implementado por Hibernate Validator) te ayuda a hacer esto con anotaciones, en lugar de escribir montones de if-else manuales.
Este artículo cubre cómo configurar, usar @Valid junto con anotaciones comunes, validar objetos anidados, escribir validators personalizados, y manejar errores de validación para devolver una respuesta amigable al cliente. Después de leerlo, tendrás confianza para aplicar validación a todos los DTOs de tu proyecto Spring Boot.
2. Agregar la dependencia
Spring Boot no incluye Bean Validation por defecto, necesitas agregar spring-boot-starter-validation:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Esta dependencia trae consigo Hibernate Validator – la implementación más común de la API Bean Validation. Si usas spring-boot-starter-web, muchas personas piensan que la validación ya está incluida, pero de hecho desde Spring Boot 2.3, este starter se ha separado – sin él, @Valid no tendrá efecto y no reportará error alguno, los datos inválidos seguirán pasando.
3. Validar el request body con @Valid
3.1. Declarar DTO con anotaciones de constraint
public record CreateOrderRequest(
@NotBlank(message = "El nombre del cliente no debe estar vacío")
String customerName,
@Email(message = "El email no es válido")
@NotBlank
String email,
@Positive(message = "La cantidad debe ser mayor que 0")
Integer quantity,
@DecimalMin(value = "0.0", inclusive = false, message = "El precio debe ser mayor que 0")
BigDecimal unitPrice
) {}
Cada campo tiene una o más anotaciones de constraint: @NotBlank no permite null/vacío/solo-espacios-en-blanco, @Email verifica el formato de email, @Positive requiere un número positivo, @DecimalMin establece un valor mínimo. El parámetro message es el contenido del error que se devuelve cuando se viola la restricción.
3.2. Aplicar @Valid en el controller
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@PostMapping
public ResponseEntity<String> createOrder(@Valid @RequestBody CreateOrderRequest request) {
// request se garantiza que sea válido aquí
return ResponseEntity.ok("Order created for " + request.customerName());
}
}
@Valid colocado antes de @RequestBody le dice a Spring MVC que ejecute la validación en el objeto recién deserializado del JSON. Si algún campo viola una restricción, Spring lanza MethodArgumentNotValidException antes de que se ejecute el cuerpo del método – es decir, no necesitas verificar manualmente null o vacío en tu lógica de negocio.
Enviar una request sin customerName:
POST /api/orders
{
"email": "a@b.com",
"quantity": 2,
"unitPrice": 10.5
}
Por defecto (sin manejo de excepciones), Spring devuelve HTTP 400 con un body bastante largo y difícil de leer para el cliente. La sección 5 cubrirá cómo manejar esto de forma más limpia.
4. Anotaciones comunes
| Anotación | Significado |
|---|---|
@NotNull |
El valor no debe ser null |
@NotBlank |
String no null, no vacío, no solo espacios en blanco |
@NotEmpty |
Collection/String/Array no null y no vacío |
@Size(min, max) |
Longitud de String o tamaño de Collection |
@Min / @Max |
Límites para números enteros |
@Positive / @Negative |
Número positivo/negativo |
@Email |
Formato de email |
@Pattern(regexp) |
Coincidencia con regex |
@Past / @Future |
Fecha en el pasado/futuro |
Ejemplo combinando múltiples constraints en un campo:
public class Customer {
@Size(min = 3, max = 50, message = "El nombre debe tener de 3 a 50 caracteres")
private String name;
@Pattern(regexp = "^0\\d{9}$", message = "El número de teléfono debe tener 10 dígitos, comenzando con 0")
private String phone;
@Past(message = "La fecha de nacimiento debe estar en el pasado")
private LocalDate birthDate;
}
Puedes adjuntar múltiples anotaciones en el mismo campo – por ejemplo @NotBlank combinado con @Size – para prevenir vacío y limitar la longitud al mismo tiempo. El validator ejecuta todos los constraints y recolecta los errores, no se detiene en el primer error.
5. Manejar errores de validación con @ExceptionHandler
En lugar de dejar que Spring devuelva una response por defecto desordenada, usa @RestControllerAdvice para reformatearla:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationErrors(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
errors.put(error.getField(), error.getDefaultMessage());
}
return ResponseEntity.badRequest().body(errors);
}
}
MethodArgumentNotValidException contiene BindingResult con una lista de FieldError – cada error tiene el nombre del campo y el mensaje. El código anterior recolecta todos los errores en un Map<field, message> y devuelve HTTP 400.
Resultado de enviar de nuevo la request sin customerName:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"customerName": "El nombre del cliente no debe estar vacío"
}
La response ahora es concisa, el cliente puede mapear fácilmente los errores a cada campo en el formulario.
6. Validar objetos anidados y colecciones
6.1. Objeto anidado con @Valid
public record AddressRequest(
@NotBlank String city,
@NotBlank String street
) {}
public record CustomerRequest(
@NotBlank String name,
@Valid
@NotNull
AddressRequest address
) {}
Si CustomerRequest.address solo tiene @NotNull pero le falta @Valid, los constraints dentro de AddressRequest (como @NotBlank en city) no serán verificados – este es un error muy común al comenzar con Bean Validation. @Valid aquí requiere que el validator “profundice” en el objeto anidado.
6.2. Validar cada elemento en una List
public record BulkOrderRequest(
@NotEmpty(message = "La lista de items no debe estar vacía")
@Valid
List<@Valid OrderItem> items
) {}
public record OrderItem(
@NotBlank String productCode,
@Positive Integer quantity
) {}
@Valid colocado justo antes de List<...> (o en el tipo de argumento) le dice al validator que verifique cada elemento en la lista, no solo si la lista es null. Si el elemento 2 en la lista tiene quantity = -1, el error devuelto especificará el campo como items[1].quantity.
7. Escribir un validator personalizado
Cuando los constraints incluidos no son suficientes, creas tu propia anotación. Por ejemplo, verificar que el código de pedido comience con ORD-:
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderCodeValidator.class)
public @interface ValidOrderCode {
String message() default "El código de pedido debe tener la forma ORD-xxxxx";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class OrderCodeValidator implements ConstraintValidator<ValidOrderCode, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true; // dejar que @NotNull lo maneje por separado, evitar errores duplicados
}
return value.matches("^ORD-\\d{5}$");
}
}
@Constraint(validatedBy = ...) vincula la anotación a la clase que maneja la lógica. ConstraintValidator<A, T> recibe A como la anotación, T como el tipo de datos del campo a validar. El método isValid devuelve true si es válido. Nota: deberías hacer que isValid devuelva true cuando value == null, porque forzar no-null debe ser manejado por @NotNull sola – evita que un campo sea reportado como error dos veces con dos mensajes diferentes.
Usar en el DTO:
public record OrderLookupRequest(
@NotBlank
@ValidOrderCode
String orderCode
) {}
8. Test de validación con JUnit 5 + AssertJ
Puedes test el Validator directamente sin iniciar el contexto de Spring MVC, más rápido que integration tests:
class CreateOrderRequestValidationTest {
private static Validator validator;
@BeforeAll
static void setUp() {
try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) {
validator = factory.getValidator();
}
}
@Test
void shouldRejectBlankCustomerName() {
CreateOrderRequest request = new CreateOrderRequest(
"", "a@b.com", 2, BigDecimal.TEN);
Set<ConstraintViolation<CreateOrderRequest>> violations = validator.validate(request);
assertThat(violations)
.extracting(v -> v.getPropertyPath().toString())
.contains("customerName");
}
@Test
void shouldPassWhenAllFieldsValid() {
CreateOrderRequest request = new CreateOrderRequest(
"Nguyen Van A", "a@b.com", 2, BigDecimal.TEN);
Set<ConstraintViolation<CreateOrderRequest>> violations = validator.validate(request);
assertThat(violations).isEmpty();
}
}
Validation.buildDefaultValidatorFactory() crea un Validator independiente, sin necesidad del contexto Spring. validator.validate(request) devuelve Set<ConstraintViolation> – vacío significa que es válido. Este enfoque de testing es adecuado para unit test de DTOs por separado, aislado de la lógica del controller.
9. Validar path variables y query parameters
@Valid en @RequestBody es lo más común, pero también puedes validar parámetros individuales añadiendo @Validated a la clase del controller:
@RestController
@RequestMapping("/api/orders")
@Validated
public class OrderQueryController {
@GetMapping("/{id}")
public ResponseEntity<String> getOrder(
@PathVariable @Positive(message = "El ID debe ser mayor que 0") Long id) {
return ResponseEntity.ok("Order " + id);
}
}
@Validated (de Spring, diferente de @Valid de Bean Validation) activa la validación a nivel de parámetros del método. Cuando id = -1, Spring lanza ConstraintViolationException (diferente de MethodArgumentNotValidException en la sección 5) – necesitas un @ExceptionHandler separado para esta excepción:
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<String> handleConstraintViolation(ConstraintViolationException ex) {
return ResponseEntity.badRequest().body(ex.getMessage());
}
Nota: Estas dos excepciones son fáciles de confundir –
MethodArgumentNotValidExceptiones para@Valid @RequestBody/@ModelAttribute, mientras queConstraintViolationExceptiones para@Validateden path variables/query parameters. Sin uno de los dos handlers, el error caerá a una response 500 por defecto que se ve muy mal.
10. Errores comunes y cuándo considerar alternativas
- Olvidar
@Valid: las anotaciones de constraint no se activan por sí solas, debes declarar@Validen el lugar correcto (parámetro del controller, campo de objeto anidado, argumento de tipo genérico). - Falta la dependencia starter-validation: las anotaciones de constraint se ignoran silenciosamente, ninguna excepción se lanza – muy difícil de detectar sin escribir tests.
- Lógica de validación compleja abarcando múltiples campos (por ejemplo, “la fecha de fin debe ser después de la fecha de inicio”): Bean Validation aún puede hacerlo a través de constraints a nivel de clase, pero si la lógica depende de datos de la base de datos (por ejemplo, “el email aún no existe”), manejalo en la capa de servicio en lugar del validator – porque el validator no debería llamar al repository, lo haría difícil de testear y violaría el principio de responsabilidad única.
- Mensajes de error multilingües: usa
message = "{order.code.invalid}"y un archivomessages.propertiesen lugar de hard-codear vietnamita en la anotación, si el proyecto necesita i18n.
11. Conclusión
Bean Validation te ayuda a declarar reglas de validación de datos directamente en el DTO usando anotaciones, en lugar de código if-else disperso. Combinando @Valid/@Validated con @RestControllerAdvice, tienes un flujo de validación consistente – reporte de errores uniforme en toda tu API. Cuando los constraints incluidos no son suficientes, los validators personalizados permiten extender de forma flexible mientras se mantiene el estilo declarativo, fácil de leer y fácil de testear.