Validation in Spring Boot with Bean Validation

1. Overview

When building a REST API, you always need to validate input data before processing business logic: email must be in the correct format, name cannot be blank, age must be positive… Bean Validation (JSR 380, implemented by Hibernate Validator) helps you do this using annotations, instead of writing countless manual if-else checks.

This article covers configuration, using @Valid with common annotations, validating nested objects, writing custom validators, and handling validation errors to return friendly responses to clients. After reading, you’ll confidently apply validation to every DTO in your Spring Boot project.

2. Adding the Dependency

Spring Boot doesn’t include Bean Validation by default; you need to add spring-boot-starter-validation:

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

This dependency brings Hibernate Validator – the most popular implementation of the Bean Validation API. If you use spring-boot-starter-web, many assume validation is already included, but in reality, since Spring Boot 2.3, this starter has been separated – without it, @Valid won’t work and won’t report any errors, allowing invalid data to pass through.

3. Validating Request Body with @Valid

3.1. Declaring DTOs with Constraint Annotations

public record CreateOrderRequest(
        @NotBlank(message = "Customer name cannot be blank")
        String customerName,

        @Email(message = "Email is invalid")
        @NotBlank
        String email,

        @Positive(message = "Quantity must be greater than 0")
        Integer quantity,

        @DecimalMin(value = "0.0", inclusive = false, message = "Price must be greater than 0")
        BigDecimal unitPrice
) {}

Each field gets one or more constraint annotations: @NotBlank disallows null/empty/whitespace-only strings, @Email checks email format, @Positive requires positive numbers, @DecimalMin sets a minimum value limit. The message parameter is the error message returned when the constraint is violated.

3.2. Applying @Valid in the Controller

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @PostMapping
    public ResponseEntity<String> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        // request is guaranteed to be valid here
        return ResponseEntity.ok("Order created for " + request.customerName());
    }
}

@Valid placed before @RequestBody tells Spring MVC to run validation on the object just deserialized from JSON. If any field violates a constraint, Spring throws MethodArgumentNotValidException before the method body executes – meaning you don’t need to manually check for null or empty values in your business logic anymore.

Sending a request missing customerName:

POST /api/orders
{
  "email": "a@b.com",
  "quantity": 2,
  "unitPrice": 10.5
}

By default (without exception handling), Spring returns HTTP 400 with a lengthy and hard-to-read response body for the client. Section 5 will handle this more elegantly.

4. Common Annotations

Annotation Meaning
@NotNull Value must not be null
@NotBlank String must not be null, empty, or contain only whitespace
@NotEmpty Collection/String/Array must not be null or empty
@Size(min, max) Length of String or size of Collection
@Min / @Max Numeric bounds
@Positive / @Negative Positive/negative number
@Email Email format
@Pattern(regexp) Regex matching
@Past / @Future Date in the past/future

Example combining multiple constraints on one field:

public class Customer {

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

    @Pattern(regexp = "^0\\d{9}$", message = "Phone must have 10 digits, starting with 0")
    private String phone;

    @Past(message = "Birth date must be in the past")
    private LocalDate birthDate;
}

You can attach multiple annotations to the same field – for example, combining @NotBlank with @Size – to both prevent empty values and limit the length. The validator runs all constraints and collects errors; it doesn’t stop at the first one.

5. Handling Validation Errors with @ExceptionHandler

Instead of letting Spring return a messy default response, use @RestControllerAdvice to format it nicely:

@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 contains BindingResult with a list of FieldError – each error has a field name and message. The code above collects all errors into a Map<field, message> and returns HTTP 400.

Response when calling the request missing customerName again:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "customerName": "Customer name cannot be blank"
}

The response is now concise, making it easy for the client to map errors to each form field.

6. Validating Nested Objects and Collections

6.1. Nested Objects with @Valid

public record AddressRequest(
        @NotBlank String city,
        @NotBlank String street
) {}

public record CustomerRequest(
        @NotBlank String name,

        @Valid
        @NotNull
        AddressRequest address
) {}

If CustomerRequest.address only has @NotNull but lacks @Valid, constraints inside AddressRequest (like @NotBlank on city) will not be checked – this is a very common mistake when starting with Bean Validation. @Valid here tells the validator to “dive deeper” into the nested object.

6.2. Validating Each Element in a List

public record BulkOrderRequest(
        @NotEmpty(message = "Item list cannot be empty")
        @Valid
        List<@Valid OrderItem> items
) {}

public record OrderItem(
        @NotBlank String productCode,
        @Positive Integer quantity
) {}

@Valid placed right before List<...> (or on the type argument) tells the validator to check each element in the list, not just whether the list is null. If the 2nd item in the list has quantity = -1, the returned error will specify the field as items[1].quantity.

7. Writing Custom Validators

When built-in constraints aren’t enough, create your own annotation. For example, checking that an order code must start with ORD-:

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderCodeValidator.class)
public @interface ValidOrderCode {
    String message() default "Order code must have the format 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; // let @NotNull handle this separately, avoid duplicate errors
        }
        return value.matches("^ORD-\\d{5}$");
    }
}

@Constraint(validatedBy = ...) links the annotation to the logic implementation class. ConstraintValidator<A, T> takes A as the annotation and T as the field type. The isValid method returns true if valid. Note: isValid should return true when value == null, since null enforcement should be delegated to @NotNull separately – avoiding a field being flagged twice with different messages.

Using in a DTO:

public record OrderLookupRequest(
        @NotBlank
        @ValidOrderCode
        String orderCode
) {}

8. Testing Validation with JUnit 5 + AssertJ

You can test Validator directly without starting the Spring MVC context, which is faster than 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() creates a standalone Validator without needing a Spring context. validator.validate(request) returns Set<ConstraintViolation> – empty means valid. This approach is ideal for unit testing DTOs separately from controller logic.

9. Validating Path Variables and Query Parameters

@Valid on @RequestBody is most common, but you can also validate individual parameters by adding @Validated to the controller class:

@RestController
@RequestMapping("/api/orders")
@Validated
public class OrderQueryController {

    @GetMapping("/{id}")
    public ResponseEntity<String> getOrder(
            @PathVariable @Positive(message = "ID must be greater than 0") Long id) {
        return ResponseEntity.ok("Order " + id);
    }
}

@Validated (from Spring, different from Bean Validation’s @Valid) enables validation at the method parameter level. When id = -1, Spring throws ConstraintViolationException (different from MethodArgumentNotValidException in section 5) – you need a separate @ExceptionHandler for this exception:

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<String> handleConstraintViolation(ConstraintViolationException ex) {
    return ResponseEntity.badRequest().body(ex.getMessage());
}

Note: These two exceptions are easy to mix up – MethodArgumentNotValidException is for @Valid @RequestBody/@ModelAttribute, while ConstraintViolationException is for @Validated on path variables/query parameters. Without one of these handlers, errors will fall back to the default ugly 500 response.

10. Common Pitfalls and When to Consider Alternatives

  • Forgetting @Valid: constraint annotations don’t activate by themselves; you must declare @Valid in the right place (controller parameter, nested object field, or type argument of a generic).
  • Missing the starter-validation dependency: constraint annotations are silently ignored, with no exceptions thrown – very hard to detect without tests.
  • Complex multi-field validation logic (e.g., “end date must be after start date”): Bean Validation can handle this via class-level constraints, but if the logic depends on database data (e.g., “email doesn’t already exist”), handle it in the service layer instead – validators shouldn’t call repositories, as that makes them hard to test and violates single responsibility.
  • Multi-language error messages: use message = "{order.code.invalid}" and messages.properties files instead of hard-coding messages in annotations, if your project needs i18n.

11. Conclusion

Bean Validation lets you declare data validation rules directly on your DTOs using annotations, replacing scattered if-else code. Combined with @Valid/@Validated and @RestControllerAdvice, you get a consistent validation flow and error reporting across your entire API. When built-in constraints aren’t enough, custom validators let you extend functionality flexibly while maintaining a declarative, easy-to-read, easy-to-test style.

spring-bootvalidationbean-validationjavarest-api