Bean Validation

Bean Validation in Spring Boot is a framework for validating the properties of a JavaBean using annotations. It leverages the Java Bean Validation API (JSR 380) and Hibernate Validator to perform validation. It

  • Ensure data integrity by validating user input at the application layer.

  • Reduce boilerplate validation logic in your code by relying on declarative annotations.

  • Integrates seamlessly with Spring Boot's controller and service layers.

  • To use bean validation, use starter

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

Common Bean Validation Annotations

Annotation

Description

@NotNull

Ensures the property is not null.

@NotEmpty

Ensures the property is not null or empty (only for Strings and collections).

@NotBlank

Ensures the property is not null or blank (for Strings).

@Size

Restricts the size of a String, collection, array, or map.

@Min / @Max

Sets minimum and maximum values for numeric fields.

@Email

Validates an email format.

@Pattern

Validates against a regular expression.

@Positive / @Negative

Ensures the property is positive or negative.

@Valid

Triggers validation for nested objects.

@Past / @Future

Ensures the property is a date in the past or future.

To validate bean object we can use @Valid in controller or service layer

How to handle exceptions when validation fails?

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

How to validate nested objects?

Use @Valid on nested objects to trigger validation:

public class Order {
    @Valid
    @NotNull
    private User user;
}

How to do groups and conditional validation?

To do group or conditional validation, we can use groups attribute and @Validated .

// first create groups
public interface CreateGroup {}
public interface UpdateGroup {}

// attach group with groups attrubute
public class User {

    @NotNull(groups = CreateGroup.class)
    private String name;

    @NotNull(groups = UpdateGroup.class)
    private Integer id;
}

// use @validated with specific group that want to validate
@PostMapping
public ResponseEntity<String> createUser(@Validated(CreateGroup.class) @RequestBody User user) {
   return ResponseEntity.ok("User created");
}

How to do custom validation of beans?

To do custom validation of beans we can use validation factory and the validator class


public class BeanValidator {

    private static final ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    private static final Validator validator = factory.getValidator();

    public static <T> void validate(T bean) {
        Set<ConstraintViolation<T>> violations = validator.validate(bean);
        if (!violations.isEmpty()) {
            StringBuilder sb = new StringBuilder();
            for (ConstraintViolation<T> violation : violations) {
                sb.append(violation.getPropertyPath()).append(": ").append(violation.getMessage()).append("\n");
            }
            throw new IllegalArgumentException("Bean validation failed:\n" + sb.toString());
        }
    }
}

How to create own custom validation constraints?

To create our own custom validation constraints we need to create a custom annotation a validator class by extending ConstraintValidator.

Below there is an example of custom constraint for validate file.

// custom validation annotation

@Documented
@Constraint(validatedBy = FileValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidFile {
    String message() default "Invalid file(s)";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    long maxSizeInMb() default 5 ; // 5MB default
    String[] allowedExtensions() default {"jpg", "jpeg", "png", "pdf"};
}

A custom validator to validate it.


import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.springframework.web.multipart.MultipartFile;

import java.util.Arrays;
import java.util.List;

/**
 * Validator class for @ValidFile annotation, used to validate file size and extension
 * @see ValidFile
 * @Author saurabh vaish
 * @Date 04-09-2024
 */
public class FileValidator implements ConstraintValidator<ValidFile, Object> {

    private long maxSizeBytes;
    private List<String> allowedExtensions;

    @Override
    public void initialize(ValidFile constraintAnnotation) {
        this.maxSizeBytes = constraintAnnotation.maxSizeInMb() * 1024 * 1024;
        this.allowedExtensions = Arrays.asList(constraintAnnotation.allowedExtensions());
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        return switch (value) {
            case null -> true; // null values are validated with @NotNull

            case MultipartFile multipartFile -> validateFile(multipartFile, context);
            case MultipartFile[] multipartFiles -> validateFiles(List.of(multipartFiles), context);
            case List<?> list -> validateFiles((List<MultipartFile>) list, context);
            default -> false;
        };

    }

    private boolean validateFile(MultipartFile file, ConstraintValidatorContext context) {
        if (file.isEmpty()) {
            return true; // Empty files are allowed, use @NotNull if you want to enforce file presence
        }

        String fileName = file.getOriginalFilename();
        if (fileName == null || fileName.contains("..")) {
            addConstraintViolation(context, "Invalid file name");
            return false;
        }

        String extension = getFileExtension(fileName);
        if (!allowedExtensions.contains(extension.toLowerCase())) {
            addConstraintViolation(context, "Invalid file type. Allowed types are: " + String.join(", ", allowedExtensions));
            return false;
        }

        if (file.getSize() > maxSizeBytes) {
            addConstraintViolation(context, "File size exceeds the maximum allowed size of " + (maxSizeBytes / 1024 / 1024) + "MB");
            return false;
        }

        return true;
    }

    private boolean validateFiles(List<MultipartFile> files, ConstraintValidatorContext context) {
        for (MultipartFile file : files) {
            if (!validateFile(file, context)) {
                return false;
            }
        }
        return true;
    }

    private void addConstraintViolation(ConstraintValidatorContext context, String message) {
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate(message).addConstraintViolation();
    }

    private String getFileExtension(String fileName) {
        int dotIndex = fileName.lastIndexOf('.');
        return (dotIndex == -1) ? "" : fileName.substring(dotIndex + 1);
    }
}

Use of this custom constraint

@NotNull(message = "Image file is required if imageUrl is not provided")
@Schema(description = "Image file (optional if imageUrl is provided)", type = "string", format = "binary")
@ValidFile(message = "Invalid file. Only JPG, JPEG, and PNG files up to 5MB are allowed.", maxSizeInMb = 5,allowedExtensions = {"jpg", "jpeg", "png"})
MultipartFile file

Handle Exceptions Using Problem Detail

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidationExceptions(MethodArgumentNotValidException ex) {
        ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        problemDetail.setTitle("Validation Error");
        problemDetail.setDetail("Invalid input provided");
        ex.getBindingResult().getFieldErrors().forEach(error ->
            problemDetail.setProperty(error.getField(), error.getDefaultMessage())
        );
        return problemDetail;
    }
}

// reponse
{
  "type": "about:blank",
  "title": "Validation Error",
  "status": 400,
  "detail": "Invalid input provided",
  "instance": "/api/users",
  "name": "Name must not be blank",
  "email": "Email must be a valid address"
}

Last updated