Overview

You can create a Custom Validator to implement your own validation logic when the built-in annotations are not sufficient

  • Example when you need to make one
    • When you need to validate that a string is not blank (beyond null checks)
    • When you need to verify that an enum value is included in a specific set of allowed values
    • When you need to compare the values of two different fields
  • Convenient when dealing with enums!

Steps

  1. Define a custom annotation (@interface)
  2. Implement the ConstraintValidator interface and write the validation logic
  3. Apply the custom annotation to the relevant field(s) in your DTO class

Example of making a custom validator

1. Define a custom annotation (@interface)

  • just metadata
  • Includes
    • the default error message
    • optional groups and payload (Bean Validation requires these)
    • the link to the class that actually runs the check (validatedBy = NotSpaceValidator.class).
// Uses Constraint annotation and Payload interface from javax.validation package
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
 
/**
 * Custom annotation to validate that a string is not composed solely of whitespace.
 * - Ensures the value is not null and does not consist only of whitespace characters (" ")
 * - The actual validation logic is implemented in NotSpaceValidator.class
 */
@Target(ElementType.FIELD) // This annotation can only be applied to fields
@Retention(RetentionPolicy.RUNTIME) // Must be retained at runtime so validation can occur
@Constraint(validatedBy = {NotSpaceValidator.class}) // Specifies the Validator class that performs the check
public @interface NotSpace {
 
    /**
     * Default message returned when validation fails
     * - Can be overridden using @NotSpace(message = "...")
     */
    String message() default "Must not be blank";
 
    /**
     * groups attribute
     * - Used to specify Bean Validation groups
     * - Rarely used in simple cases, but included for extensibility
     */
    Class<?>[] groups() default {};
 
    /**
     * payload attribute
     * - An extension point for carrying metadata
     * - Not commonly used, but useful when integrating with certain frameworks
     */
    Class<? extends Payload>[] payload() default {};
}
  • @Target(ElementType.FIELD)
    • ā€œWhere can I put this sticker?ā€
    • ElementType.FIELD → you can only apply @NotSpace to class fields (variables). You couldn’t put it on a whole class or a method.
  • @Retention(RetentionPolicy.RUNTIME)
    • This answers, ā€œHow long should this sticker last?ā€
    • RetentionPolicy.RUNTIME → the annotation’s information must be available to the Java Virtual Machine (JVM) while the program is running.
    • This is essential because the validation framework needs to read the annotation at runtime to perform the check.
  • @Constraint(validatedBy = {NotSpaceValidator.class})
    • This is the most important part. It’s the link that connects your annotation sticker (@NotSpace) to the class that contains the actual validation logic (NotSpaceValidator).
    • It tells the validation framework, ā€œWhen you see the @NotSpace sticker, go run the code inside the NotSpaceValidator class to check if the rule is broken.ā€
  • message(), groups(), payload() →These are mandatory attributes required by the Bean Validation specification
    • message(): Defines the default error message to show if validation fails.
    • groups(): This is an advanced feature for grouping validation rules. For example, you could have one group of rules for creating a user and another for updating them.
    • payload(): An even more advanced feature for carrying extra metadata with your annotation. You also just leave it as a default.

2. Implement the ConstraintValidator interface and write the validation logic

import org.springframework.util.StringUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
 
// we just made NotSpace!
public class NotSpaceValidator implements ConstraintValidator<NotSpace, String> {
 
    @Override
    public void initialize(NotSpace constraintAnnotation) {
        // Initialization logic (if needed)
    }
 
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // Allow null, but reject strings that contain only whitespace
        return value == null || StringUtils.hasText(value);
    }
}
  • public class NotSpaceValidator implements ConstraintValidator<NotSpace, String>
    • Implement the ConstraintValidator<A, T> interface.
      • A: The custom annotation type (@NotSpace)
      • T: The type of the field being validated (String)
  • Methods
    • initialize()
      • Called once before validation begins
      • purpose: read configuration from your annotation
      • For a simple annotation like @NotSpace, you don’t need any configuration, so the method is empty.
      • However, imagine if your annotation was @MaxWordCount(value = 10). You would use the initialize() method to get that 10 and save it so isValid() can use it later
    • isValid() – Returns true if:
      • Every time bean validation sees @NotSpace on a field, it calls isValid(...) to decide if the value is OK.
    • These methods can be automatically created in Intellij (empty methods)

3. Applying to DTO

public class MemberPatchDto {
    private long memberId;
 
    @NotSpace(message = "Member name must not be blank")
    private String name;
 
    @Pattern(
        regexp = "^010-\\d{3,4}-\\d{4}$",
        message = "Phone number must start with 010 and be in the format of 11 digits with '-'")
    private String phone;
 
    // Getters/Setters omitted
}

Custom Validator VS Regex

ItemRegular Expression-based ValidationCustom Validator-based Validation
AdvantagesConcise and reusableSupports complex logic, better readability
DisadvantagesPotential performance issues, complex regexRequires implementation, somewhat verbose
ExamplesUsing @Pattern@NotSpace, Enum validation, etc.
  • Avoid overly complex regex for validation → Catastrophic Backtracking
    • regex engines often work by trying to match a pattern, and if they fail, they backtrack to a previous point and try a different path
    • Catastrophic Backtracking happens when a complex regex with nested, repetitive patterns (like (a+)+) tries to match a string that almost fits but doesn’t. The regex engine has to try an exponential number of paths before it can finally give up and say ā€œno match.ā€ This can cause the CPU to spike to 100% and freeze your application for seconds or even minutes.
  • When your validation logic becomes complex, a custom validation annotation is often a better choice
  • When to use each
    • Regex - for simple, well-defined patterns (emails, zip codes, phone numbers).
    • Custom validator - the logic is complex, involves multiple conditions, or requires high performance and reliability

Tips

Designing with Null Acceptance in Mind

  • Most validators consider null as a valid value.
  • Because of this, you should either combine them with @NotNull or explicitly perform null checks within the validator itself.

Internationalization of Messages

  • By using message properties (e.g., ValidationMessages.yml), you can handle custom messages in multiple languages.
  • Example message properties:
NotSpace:
  message: ź³µė°±ģ¼ 수 ģ—†ģŠµė‹ˆė‹¤  # "Cannot be blank"
  • Example annotation usage
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = NotSpaceValidator.class)
public @interface NotSpace {
    String message() default "{NotSpace.message}";  // šŸ”¹ Use key for i18n
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}