Dung (Donny) Nguyen

Senior Software Engineer

Bean Validation

Bean Validation is a Java specification (JSR 380, also known as Bean Validation 2.0) that provides a standardized way to enforce constraints on object fields and method parameters using annotations. In Spring Boot, it is the go-to mechanism for validating incoming request data, ensuring that only well-formed input reaches your business logic.

The reference implementation is Hibernate Validator, which Spring Boot auto-configures when you add the validation starter.


Why Use Bean Validation


Adding the Dependency

Bean Validation is included through the spring-boot-starter-validation starter:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Note: In older Spring Boot versions, validation was bundled with spring-boot-starter-web, but since Spring Boot 2.3 you must add it explicitly.


Common Validation Annotations

Annotation Applies To Description
@NotNull Any type Value must not be null.
@NotEmpty String, Collection, Map, Array Must not be null and size/length > 0.
@NotBlank String Must not be null and must contain at least one non-whitespace character.
@Size(min, max) String, Collection, Map, Array Size must be within the given range.
@Min / @Max Numbers Value must be above/below a limit.
@Positive / @Negative Numbers Value must be strictly positive/negative.
@Email String Must be a well-formed email address.
@Pattern(regexp) String Must match the given regular expression.
@Past / @Future Dates Must be in the past/future.
@Digits(integer, fraction) Numbers Restricts the number of integer and fraction digits.

Defining Constraints on a Model

import jakarta.validation.constraints.*;

public class UserRequest {

    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 50, message = "Name must be between 2 and 50 characters")
    private String name;

    @NotNull(message = "Email is required")
    @Email(message = "Email must be valid")
    private String email;

    @Min(value = 18, message = "Age must be at least 18")
    @Max(value = 120, message = "Age must be less than 120")
    private int age;

    @Pattern(regexp = "^\\+?[0-9]{10,15}$", message = "Invalid phone number")
    private String phone;

    // getters and setters
}

In Spring Boot 3.x, validation annotations live in the jakarta.validation.constraints package. In Spring Boot 2.x, they are under javax.validation.constraints.


Validating Request Bodies with @Valid

Annotate the controller method parameter with @Valid to trigger validation on the incoming JSON body:

import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/users")
public class UserController {

    @PostMapping
    public ResponseEntity<String> createUser(@Valid @RequestBody UserRequest request) {
        // If validation fails, this method is never reached
        return ResponseEntity.ok("User created: " + request.getName());
    }
}

When validation fails, Spring throws a MethodArgumentNotValidException, which by default returns an HTTP 400 Bad Request.


Validating Path Variables and Request Parameters

For single parameters, add @Validated at the class level and apply constraints directly to the arguments:

import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.Min;

@RestController
@RequestMapping("/api/products")
@Validated
public class ProductController {

    @GetMapping("/{id}")
    public ResponseEntity<String> getProduct(@PathVariable @Min(1) Long id) {
        return ResponseEntity.ok("Product " + id);
    }
}

Violations here raise a ConstraintViolationException.


Handling Validation Errors Gracefully

Use a @ControllerAdvice to convert validation errors into a clean, structured response:

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class ValidationExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
            errors.put(error.getField(), error.getDefaultMessage())
        );
        return errors;
    }
}

A failed request now returns a readable JSON body:

{
  "name": "Name is required",
  "email": "Email must be valid",
  "age": "Age must be at least 18"
}

Nested and Group Validation

Nested objects are validated by placing @Valid on the field:

public class OrderRequest {

    @NotNull
    @Valid
    private UserRequest customer;

    @NotEmpty
    private List<@Valid OrderItem> items;
}

Validation groups let you apply different rules in different contexts (e.g., create vs. update):

public interface OnCreate {}
public interface OnUpdate {}

public class UserRequest {

    @Null(groups = OnCreate.class)
    @NotNull(groups = OnUpdate.class)
    private Long id;

    @NotBlank(groups = {OnCreate.class, OnUpdate.class})
    private String name;
}
@PostMapping
public ResponseEntity<String> create(@Validated(OnCreate.class) @RequestBody UserRequest request) {
    return ResponseEntity.ok("Created");
}

Creating a Custom Constraint

When built-in annotations aren’t enough, define your own. For example, a constraint that checks a username is lowercase:

1. Define the annotation:

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;

@Documented
@Constraint(validatedBy = LowercaseValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface Lowercase {
    String message() default "Value must be lowercase";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

2. Implement the validator:

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class LowercaseValidator implements ConstraintValidator<Lowercase, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return value == null || value.equals(value.toLowerCase());
    }
}

3. Use it like any other annotation:

@Lowercase
private String username;

Best Practices


Conclusion

Bean Validation gives us a clean, declarative, and standardized way to guard our application against invalid input. By combining built-in annotations, @Valid/@Validated, global exception handling, and custom constraints, we can keep validation logic maintainable and our business code focused on what matters.