DEV Community

Cover image for Creating Custom Validators in Spring Data: A Comprehensive Guide
Parzival
Parzival

Posted on

Creating Custom Validators in Spring Data: A Comprehensive Guide

Spring Data's validation framework provides robust built-in validators, but sometimes we need custom validation logic for specific business rules. In this article, I'll show you how to create and implement custom validators in Spring Data.

Understanding Custom Validation

Custom validators in Spring allow us to define specific validation rules that aren't covered by standard annotations like @NotNull or @Size. They're particularly useful when dealing with complex business logic or domain-specific validation requirements.

Creating a Custom Validator

Let's create a custom validator that checks if a string follows a specific business format. Here's a step-by-step example:

  1. First, create a custom annotation:
@Documented
@Constraint(validatedBy = BusinessCodeValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidBusinessCode {
    String message() default "Invalid business code format";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
Enter fullscreen mode Exit fullscreen mode
  1. Implement the validator class:
public class BusinessCodeValidator implements ConstraintValidator<ValidBusinessCode, String> {

    @Override
    public void initialize(ValidBusinessCode constraintAnnotation) {
        // Initialization logic if needed
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return true; // Let @NotNull handle null checking
        }

        // Custom validation logic
        return value.matches("^BC-[0-9]{4}-[A-Z]{2}$");
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Apply the validator to your entity:
@Entity
public class Business {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ValidBusinessCode
    private String businessCode;

    // getters and setters
}
Enter fullscreen mode Exit fullscreen mode

Advanced Validation Features

Composite Validators

Sometimes you need to combine multiple validation rules. Here's how to create a composite validator:

@Documented
@Constraint(validatedBy = CompositeValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidBusinessEntity {
    String message() default "Business validation failed";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class CompositeValidator implements ConstraintValidator<ValidBusinessEntity, Business> {

    @Override
    public boolean isValid(Business business, ConstraintValidatorContext context) {
        boolean isValid = true;

        if (!isValidBusinessCode(business.getBusinessCode())) {
            context.buildConstraintViolationWithTemplate("Invalid business code")
                   .addPropertyNode("businessCode")
                   .addConstraintViolation();
            isValid = false;
        }

        if (!isValidDateRange(business.getStartDate(), business.getEndDate())) {
            context.buildConstraintViolationWithTemplate("Invalid date range")
                   .addPropertyNode("dateRange")
                   .addConstraintViolation();
            isValid = false;
        }

        return isValid;
    }
}
Enter fullscreen mode Exit fullscreen mode

Cross-Field Validation

For validations that involve multiple fields:

@ValidDateRange
public class DateRange {
    private LocalDate startDate;
    private LocalDate endDate;
    // getters and setters
}

public class DateRangeValidator implements ConstraintValidator<ValidDateRange, DateRange> {

    @Override
    public boolean isValid(DateRange range, ConstraintValidatorContext context) {
        if (range.getStartDate() == null || range.getEndDate() == null) {
            return true;
        }

        return !range.getStartDate().isAfter(range.getEndDate());
    }
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

  1. Separation of Concerns: Keep validation logic isolated in dedicated validator classes.

  2. Meaningful Messages: Provide clear, user-friendly validation messages:

@ValidBusinessCode(message = "Business code must follow format: BC-XXXX-YY")
private String businessCode;
Enter fullscreen mode Exit fullscreen mode
  1. Null Handling: Be explicit about null handling in your validators:
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
    if (value == null) {
        return true; // Or false, depending on your requirements
    }
    // validation logic
}
Enter fullscreen mode Exit fullscreen mode
  1. Context-Specific Validation: Use validation groups for different validation contexts:
public interface CreateValidation {}
public interface UpdateValidation {}

@ValidBusinessCode(groups = {CreateValidation.class})
private String businessCode;
Enter fullscreen mode Exit fullscreen mode

Testing Custom Validators

Don't forget to test your validators:

@Test
public void testBusinessCodeValidator() {
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();

    Business business = new Business();
    business.setBusinessCode("invalid-code");

    Set<ConstraintViolation<Business>> violations = validator.validate(business);
    assertFalse(violations.isEmpty());
    assertEquals("Invalid business code format", 
                 violations.iterator().next().getMessage());
}
Enter fullscreen mode Exit fullscreen mode

Error Handling

Implement a global exception handler to manage validation errors:

@ControllerAdvice
public class ValidationExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, List<String>>> handleValidationErrors(
            MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult()
                               .getFieldErrors()
                               .stream()
                               .map(FieldError::getDefaultMessage)
                               .collect(Collectors.toList());
        return new ResponseEntity<>(getErrorsMap(errors), HttpStatus.BAD_REQUEST);
    }

    private Map<String, List<String>> getErrorsMap(List<String> errors) {
        Map<String, List<String>> errorResponse = new HashMap<>();
        errorResponse.put("errors", errors);
        return errorResponse;
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Custom validators in Spring Data provide a powerful way to implement complex validation rules. By following these patterns and best practices, you can create maintainable, reusable validation components that enhance your application's data integrity.

Remember to keep your validators focused, well-tested, and documented. This will make them easier to maintain and reuse across your application.

The examples provided here should give you a solid foundation for implementing your own custom validators in Spring Data projects. Happy coding!

Top comments (0)