Skip to main content

[Writing Practice]: Spring Boot and Validation: A Complete Guide with @Valid and @Validated

· 7 min read
Link Nuis
Java Developer
warning

This blogs solely for writing practice, so it's primarily a copy blog/article, not intended for content dissemination.

Root blog/article: link-blog

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 structure error responses.

Spring Boot natively supports validation via the Bean Validation API (JRS 380), implement 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>

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:

AnnotationDescriptionExample
@NotnullField cannot be null@Notnull Double price;
@NotBlankString cannot be null or empty (ignores spaces)@NotBlank String name;
@SizeLimit size of strongs, arrays, or lists@Size(max = 5)
@EmailValidates email format@Email String contactEmail;
@Min, @MaxNumeric boundaries@Min(1) @Max(10000)
@Positive, @NegativeMust be > 0 or < 0@Positive Integer quantity;
@Past, @FutureFor dates@Future LocalDate availabiltyDate;

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;
}

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;

Spring will automtically cascade the validation down into each element of the list. For complex nested DTOs (e.g. AddresRequest inside a UserRequest), you'd also annotate the nested field with @Valid.

6. @Valid and @Validated

A common source of confusion!

AnnotationUsed onSupports GroupsTypical Use Case
@ValidMethod parameters and fieldsNoValidate request bodies
@ValidatedClass level or method levelYesUsed 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) {
...
}

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) {
...
}

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

This approach is esential 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

Then reference them:

@NotBlank(message = "{product.name.notblank}")

This improves maintainablity 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 no 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 prefered choice for REST APIs, as it automatically adds @ResponseBody to all methods, returning structured JSON error responses.
  • @ControllerAdvice on other hand, is more general-purpose and suitable if your application also serves web pages or uses view templates, since it's 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);
}
}

Why should we use @ControllerAdvice

  • Centralization: all validation errors are managed in one place, improving mantainability.
  • 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.

10. Custom Validations 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 {};
}

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();
}
}

Then use it in your DTO:

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

11. Best Pratices

  • 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.
  • Externalized 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