@NotBlank and @Pattern are useful, but they cannot cover rules like “phone number format validation,” “email address duplicate check,” or “password and confirmation field match.” A common workaround is writing this logic by hand in the Service layer, but over time validation logic ends up scattered across both the Controller and Service.

By using @Constraint and ConstraintValidator, you can define these rules as custom annotations. This article walks through three implementation patterns in order.

For the basics of @Valid and @Validated, see this article. For group validation, see this article.

How It Works

Custom validation requires two components:

  • Annotation definition: an @interface annotated with @Constraint(validatedBy = ...)
  • Validator class: a class implementing ConstraintValidator<Annotation, TypeToValidate>

Spring Boot auto-configures LocalValidatorFactoryBean, and its internal SpringConstraintValidatorFactory handles DI automatically. This means field injection into Validator classes works out of the box.

Single-Field Validation (Pattern 1 — Phone Number)

Let’s start with a simple example. We’ll create a @PhoneNumber annotation that validates phone number format. By adding a regexp attribute, callers can customize the pattern.

@Documented
@Constraint(validatedBy = PhoneNumberValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface PhoneNumber {
    String message() default "{validation.phone.invalid}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    String regexp() default "^0\\d{9,10}$"; // デフォルトはモバイル向け簡易パターン
}

The three attributes message / groups / payload are required by the Bean Validation specification. Adding a regexp attribute allows you to receive its value in initialize().

public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {

    private Pattern pattern;

    @Override
    public void initialize(PhoneNumber annotation) {
        this.pattern = Pattern.compile(annotation.regexp());
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) return true; // nullは@NotNullに任せる
        return pattern.matcher(value).matches();
    }
}

Returning true when the value is null is the convention. Delegating null checks to @NotNull keeps each constraint’s responsibility clean and well-separated.

public class UserRequest {
    @NotNull
    @PhoneNumber
    private String phoneNumber;
    // ...
}

DB Lookup with DI (Pattern 2 — Email Duplicate Check)

Because Spring Boot auto-configures LocalValidatorFactoryBean and SpringConstraintValidatorFactory handles DI automatically, field injection into Validator classes works as-is. @Component is not required.

@Documented
@Constraint(validatedBy = UniqueEmailValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueEmail {
    String message() default "{validation.email.duplicate}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {

    @Autowired
    private UserRepository userRepository;

    @Override
    public boolean isValid(String email, ConstraintValidatorContext context) {
        if (email == null) return true;
        return !userRepository.existsByEmail(email);
    }
}

For JPA query methods, see this article as well.

For update scenarios where you want to “exclude the user’s own email,” you can add a long excludeId() default -1L attribute to the annotation and branch the validation logic in the Validator based on the received ID.

Cross-Field Validation (Pattern 3 — Password Confirmation)

To compare multiple fields, annotate the class itself. The key is setting @Target to ElementType.TYPE.

@Documented
@Constraint(validatedBy = PasswordMatchValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordMatch {
    String message() default "{validation.password.mismatch}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
public class PasswordMatchValidator implements ConstraintValidator<PasswordMatch, Object> {

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        // Object型を受け取るため汎用性は高いが、対象外クラスが渡るとtrueを返してスキップされる点に注意
        if (!(value instanceof PasswordResetRequest req)) return true;
        if (Objects.equals(req.getPassword(), req.getConfirmPassword())) return true;

        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate("{validation.password.mismatch}")
               .addPropertyNode("confirmPassword")
               .addConstraintViolation();
        return false;
    }
}

Using Objects.equals() prevents NPE even when either value is null. Because Bean Validation evaluates field-level and class-level constraints in parallel, null can be passed to isValid() even when @NotBlank is present. Calling req.getPassword().equals(...) directly would cause an NPE in this scenario, so be careful.

@PasswordMatch
public class PasswordResetRequest {
    @NotBlank
    private String password;

    @NotBlank
    private String confirmPassword;
    // getter/setter...
}

Internationalizing Error Messages

Define keys and values in ValidationMessages.properties (placed at the root of the classpath).

validation.phone.invalid=電話番号の形式が正しくありません(例:09012345678)
validation.email.duplicate=このメールアドレスはすでに登録されています
validation.password.mismatch=パスワードと確認用パスワードが一致しません

It is easy to confuse this with Spring Boot’s messages.properties, but Bean Validation’s message interpolation reads from ValidationMessages.properties.

Writing Tests

Validators that don’t require DI can be tested with just a ValidatorFactory.

@Test
void 電話番号バリデーションのテスト() {
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();

    UserRequest valid = new UserRequest();
    valid.setPhoneNumber("09012345678");
    assertThat(validator.validate(valid)).isEmpty();

    UserRequest invalid = new UserRequest();
    invalid.setPhoneNumber("abc");
    assertThat(validator.validate(invalid)).isNotEmpty();
}

Validators that inject a Repository, like UniqueEmailValidator, require a Spring context. Writing these as integration tests using @SpringBootTest or @WebMvcTest with MockMvc is the practical approach.

Deciding What to Move to the Service Layer

That said, not everything should be turned into an annotation.

  • Good candidates for annotation: the same rule is reused across multiple DTOs, the check is self-contained as an input validation
  • Better left in the Service layer: tightly intertwined with business logic, requires transaction management

Applying custom annotations to newly created DTOs first lets you gradually clean things up while minimizing impact on existing code. For setting up exception handling, see this article as well.

Summary

Combining @Constraint and ConstraintValidator lets you plug custom validation rules directly into the Bean Validation framework. Mastering the three patterns — single-field, DB lookup, and cross-field — covers the vast majority of real-world use cases. Give it a try as a way to consolidate the validation logic that has been scattered throughout your Service layer.