Data validation is a critical part of building secure, reliable, and user-friendly applications. Spring Boot provides robust support for bean validation using Hibernate Validator, but complex business logic often requires more than built-in constraints like @NotNull or @Email.

In this post, we’ll explore advanced data validation techniques in Spring Boot, including:

  • Creating custom validation annotations
  • Reusing constraint logic across DTOs and entities
  • Composing constraints
  • Handling validation groups and dynamic scenarios

Enabling Validation in Spring Boot

Spring Boot integrates with Hibernate Validator (the reference implementation of Bean Validation 2.0). To get started, include the dependency (already bundled with Spring Boot Starter Web):

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

Activate validation by annotating controller methods with @Valid or @Validated:

@PostMapping("/register")
public ResponseEntity<?> register(@Valid @RequestBody UserDTO dto) {
// registration logic
}

Creating a Custom Validator Annotation

Built-in constraints are limited for domain-specific rules. Let’s say we want to validate that a username is unique in the database.

Step 1: Create the Annotation

@Target({ ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueUsernameValidator.class)
public @interface UniqueUsername {
String message() default "Username is already taken";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

Step 2: Implement the Validator Logic

public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername, String> {

    @Autowired
    private UserRepository userRepository;

    @Override
    public boolean isValid(String username, ConstraintValidatorContext context) {
        return !userRepository.existsByUsername(username);
    }
}

Spring will now inject your repository and validate this constraint during form submission.


Composing Multiple Constraints

You can combine standard and custom annotations for more expressive validation:

public class UserDTO {

    @NotBlank
    @Size(min = 4, max = 20)
    @UniqueUsername
    private String username;

    @Email
    @NotBlank
    private String email;
}

Validation will cascade and fail-fast depending on your configuration.


Field-Level vs Class-Level Validation

Use class-level validation when validation logic depends on multiple fields — for example, matching passwords.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchesValidator.class)
public @interface PasswordMatches {
String message() default "Passwords do not match";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PasswordMatchesValidator implements ConstraintValidator<PasswordMatches, RegisterDTO> {
public boolean isValid(RegisterDTO dto, ConstraintValidatorContext context) {
return dto.getPassword().equals(dto.getConfirmPassword());
}
}

Apply it like this:

@PasswordMatches
public class RegisterDTO {
private String password;
private String confirmPassword;
}

Using Validation Groups

Validation groups allow you to run different validations depending on the context (e.g., create vs update).

public interface OnCreate {}
public interface OnUpdate {}

@NotBlank(groups = OnCreate.class)
private String name;

Then specify the group in your controller:

@Validated(OnCreate.class)
@PostMapping("/create")
public void create(@RequestBody UserDTO dto) { ... }

This pattern keeps your DTOs flexible without duplicating code.


Customizing Validation Error Responses

Handle validation errors globally using @RestControllerAdvice:

@RestControllerAdvice
public class ValidationExceptionHandler {

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

This provides structured error messages to clients for better UX.


Advanced Tips

  • Use ConstraintValidatorContext to customize messages dynamically
  • Add dynamic properties via @Constraint(validatedBy =...) to reuse logic
  • Use @Validated on service layers for method-level validation
  • Combine annotations with @Target({ElementType.FIELD, ElementType.PARAMETER}) for flexibility
  • Avoid calling services in validators if not necessary — cache if possible

Conclusion

Data validation is more than just checking for nulls. With custom annotations, class-level constraints, and validation groups, Spring Boot gives you powerful tools to ensure your data integrity and business rules are enforced cleanly.

By mastering advanced validation techniques, you can simplify your controllers, reduce bugs, and enforce contracts effectively across layers of your application.