DEV Community

Gianfranco Coppola
Gianfranco Coppola

Posted on

Spring Boot and Validation: A Complete Guide with @Valid and @Validated

Introduction

Data validation is one of those topics that every backend developer knows is important—but it often doesn’t get the attention it deserves. A solid validation strategy keeps your APIs clean, secure, and predictable.

In this guide, we’ll explore how to use Spring Boot validation effectively with @Valid and @Validated. You’ll learn how to:

  • Validate request DTOs cleanly
  • Handle nested objects and lists
  • Customize error messages
  • Manage validation groups for create/update operations
  • Build centralized exception handling
  • Implement custom validation annotations with ConstraintValidator

All examples are based on a simple product and category API built with Spring Boot 3, Hibernate Validator, and OpenAPI documentation.


1. Why Validation Matters

Validation helps ensure data integrity, security, and user experience:

  • Prevents invalid or malicious data from reaching your business logic.
  • Improves API predictability by enforcing consistent input rules.
  • Provides clear feedback to clients via structured error responses.

Spring Boot natively supports validation via the Bean Validation API (JSR 380), implemented by Hibernate Validator under the hood.


2. Enabling Validation

Add the spring-boot-starter-validation dependency to your project:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Once included, Spring automatically integrates it into your controller request mappings.


3. Basic Validation Annotations

Spring Boot’s validation system supports all standard JSR-380 annotations:

Annotation Description Example
@NotNull Field cannot be null @NotNull Double price
@NotBlank String cannot be null or empty (ignores spaces) @NotBlank String name
@Size Limits size of strings, arrays, or lists @Size(max = 5)
@Email Validates email format @Email String contactEmail
@Min, @Max Numeric boundaries @Min(1) @Max(10000)
@Positive, @Negative Must be > 0 or < 0 @Positive Integer quantity
@Past, @Future For dates @Future LocalDate availabilityDate

4. Example: ProductRequest DTO

Here’s a real-world DTO used in our API:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class ProductRequest {

    @NotBlank(message = "Product name cannot be blank", groups = {OnCreate.class, OnUpdate.class})
    @Size(min = 2, max = 100, message = "Product name must be between 2 and 100 characters", groups = {OnCreate.class, OnUpdate.class})
    String name;

    @NotNull(message = "Price cannot be null", groups = OnCreate.class)
    @Positive(message = "Price must be positive", groups = {OnCreate.class, OnUpdate.class})
    Double price;

    @NotNull(message = "Category ID is required", groups = OnCreate.class)
    Long categoryId;

    @Valid
    @Size(max = 5, message = "You can add up to 5 tags", groups = {OnCreate.class, OnUpdate.class})
    List<@NotBlank(message = "Tag cannot be blank") String> tags;

    @Email(message = "Invalid email format for warranty", groups = {OnCreate.class, OnUpdate.class})
    String emailForWarranty;

    @Min(value = 0, message = "Discount cannot be negative", groups = {OnCreate.class, OnUpdate.class})
    @Max(value = 80, message = "Discount cannot exceed 80%", groups = {OnCreate.class, OnUpdate.class})
    Integer discountPercentage;

    @Future(message = "Availability date must be in the future", groups = {OnCreate.class, OnUpdate.class})
    LocalDate availabilityDate;

    @Sku(message = "SKU must be 8 uppercase letters or digits", groups = {OnCreate.class, OnUpdate.class})
    String sku;
}
Enter fullscreen mode Exit fullscreen mode

This DTO combines multiple annotation types and supports both create and update operations through validation groups.


5. Nested Object and List Validation

To validate nested objects or lists, use @Valid on the field that contains them.

@Valid
List<@NotBlank(message = "Tag cannot be blank") String> tags;
Enter fullscreen mode Exit fullscreen mode

Spring will automatically cascade the validation down into each element of the list.

For complex nested DTOs (e.g. AddressRequest inside a UserRequest), you’d also annotate the nested field with @Valid.


6. @Valid vs @Validated

A common source of confusion!

Annotation Used on Supports Groups Typical Use Case
@Valid Method parameters and fields No Validate request bodies
@Validated Class level or method level Yes Used on method parameters, path variables, or when validation groups are needed

Example:

@PostMapping
public ResponseEntity<ProductResponse> createProduct(
        @Validated(OnCreate.class) @RequestBody ProductRequest request) {
    ...
}

@PutMapping("/{id}")
public ResponseEntity<ProductResponse> updateProduct(
        @PathVariable @Min(1) Long id,
        @Validated(OnUpdate.class) @RequestBody ProductRequest request) {
    ...
}
Enter fullscreen mode Exit fullscreen mode

Here, @Validated enables group-based validation, enforcing different constraints on creation vs update.

Meanwhile, in simpler cases (like categories), you can still use @Valid:

@PostMapping
public ResponseEntity<CategoryResponse> createCategory(
        @Valid @RequestBody CategoryRequest request) {
    ...
}
Enter fullscreen mode Exit fullscreen mode

7. Validation Groups Explained

Validation groups let you define which constraints apply depending on context.

public interface OnCreate {}
public interface OnUpdate {}
Enter fullscreen mode Exit fullscreen mode

Then assign them in your annotations:

@NotNull(groups = OnCreate.class)
@Positive(groups = {OnCreate.class, OnUpdate.class})
Double price;
Enter fullscreen mode Exit fullscreen mode

When validating with @Validated(OnCreate.class), only constraints belonging to that group will be triggered.

This approach is essential for APIs where fields are optional on update but mandatory on create.


8. Custom Error Messages

You can define messages directly in annotations (as shown earlier), or externalize them in a messages.properties file:

product.name.notblank=Product name cannot be blank
product.price.positive=Price must be positive
Enter fullscreen mode Exit fullscreen mode

Then reference them:

@NotBlank(message = "{product.name.notblank}")
Enter fullscreen mode Exit fullscreen mode

This improves maintainability and supports localization.


9. Global Exception Handling for Validation

When validation fails in Spring Boot, two main exceptions are typically thrown:

  • MethodArgumentNotValidException: triggered when a @Valid or @Validated annotated @RequestBody fails validation.
  • ConstraintViolationException: triggered when validation fails on method parameters, such as @PathVariable, @RequestParam, or directly on method arguments annotated with @Validated.

If not handled, these exceptions result in default error responses that are not user-friendly and may expose internal details.

To keep your API responses consistent and developer-friendly, you should implement centralized exception handling using @RestControllerAdvice (or @ControllerAdvice, depending on your use case).

  • @RestControllerAdvice is the preferred choice for REST APIs, as it automatically adds @ResponseBody to all methods, returning structured JSON error responses.
  • @ControllerAdvice, on the other hand, is more general-purpose and suitable if your application also serves web pages or uses view templates, since it doesn’t assume a JSON response by default.

Both annotations act as global interceptors for exceptions thrown from controllers, allowing you to define a single, centralized place to handle and format error responses.

A typical example looks like this:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationError(MethodArgumentNotValidException ex) {
        Map<String, Object> errors = new LinkedHashMap<>();
        errors.put("timestamp", LocalDateTime.now());
        errors.put("status", HttpStatus.BAD_REQUEST.value());
        errors.put("errors", ex.getBindingResult().getFieldErrors()
                .stream()
                .map(err -> Map.of("field", err.getField(), "message", err.getDefaultMessage()))
                .toList());
        return ResponseEntity.badRequest().body(errors);
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<Map<String, Object>> handleConstraintViolation(ConstraintViolationException ex) {
        Map<String, Object> errors = new LinkedHashMap<>();
        errors.put("timestamp", LocalDateTime.now());
        errors.put("status", HttpStatus.BAD_REQUEST.value());
        errors.put("errors", ex.getConstraintViolations()
                .stream()
                .map(v -> Map.of("field", v.getPropertyPath().toString(), "message", v.getMessage()))
                .toList());
        return ResponseEntity.badRequest().body(errors);
    }
}
Enter fullscreen mode Exit fullscreen mode

Why You Should Use @ControllerAdvice

  • Centralization: all validation errors are managed in one place, improving maintainability.
  • Consistency: every endpoint returns validation errors with the same JSON structure.
  • Security: prevents exposing internal exception details to the client.
  • Extensibility: you can easily extend the same class to handle business or authentication errors in the future.

This approach not only improves developer experience but also helps frontend teams rely on predictable, machine-readable error formats.


10. Custom Validators with ConstraintValidator

Sometimes standard annotations aren’t enough. You can create your own validator easily.

Example: SKU Validator

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SkuValidator.class)
public @interface Sku {
    String message() default "Invalid SKU format";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
Enter fullscreen mode Exit fullscreen mode

Validator implementation:

public class SkuValidator implements ConstraintValidator<Sku, String> {

    private static final Pattern SKU_PATTERN = Pattern.compile("^[A-Z0-9]{8}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return value == null || SKU_PATTERN.matcher(value).matches();
    }
}
Enter fullscreen mode Exit fullscreen mode

Then use it in your DTO:

@Sku(message = "SKU must be 8 uppercase letters or digits")
String sku;
Enter fullscreen mode Exit fullscreen mode

11. Best Practices

  • Validate at the edge — always in controllers, before reaching the service layer.
  • Group constraints logically (create/update).
  • Use @Valid for simple cases, @Validated for grouped or parameter-level validation.
  • Keep error messages clean and user-friendly.
  • Centralize exception handling with @ControllerAdvice.
  • Externalize messages for easier maintenance.
  • Prefer DTO validation over entity validation to maintain separation of concerns.

12. Conclusion

Validation is more than just checking inputs—it’s part of your API design.

Spring Boot, with Bean Validation, makes it easy to enforce correctness, improve security, and offer better UX.

We’ve covered:

  • Basic and advanced annotations
  • Nested and list validation
  • Custom constraints
  • Validation groups
  • Global error handling

🧩 Want more? Check out the GitHub repository for more examples and full implementations and my Pro Starter Kit on Gumroad!


💬 Join the Discussion

What’s your approach to validation and error handling in Spring Boot? Have you tried alternative strategies like custom ErrorResponse wrappers or problem-spring-web? Let’s discuss best practices and experiences from real-world projects.

Share your thoughts in the comments! 👇

Your feedback will help other developers (and me!) improve future articles.

Top comments (0)