@NotBlank and @Pattern are convenient, but they can’t cover rules like “phone number format validation,” “email address duplication check,” or “password and confirmation field matching.” A common approach is to write these by hand in the Service layer, but eventually validation logic ends up scattered across both Controllers and Services.

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

For the basic usage of @Valid and @Validated, see this article, and for group validation, see this article.

The Overall Mechanism

Standard Validation Annotations to Know First

Before creating custom annotations, taking a look at the annotations Bean Validation provides as standard makes it easier to judge “how far the standards take you, and where you need to roll your own.”

AnnotationTarget TypePurpose
@NotNullAnyDisallows null
@NotBlankStringDisallows null / empty string / whitespace only
@NotEmptyString/Collection/Map/ArrayDisallows null / empty
@Size(min, max)String/Collection, etc.Specifies range of length or element count
@Min / @MaxNumericLower/upper bound for numeric values
@EmailStringRFC-compliant email format
@Pattern(regexp = ...)StringMatches an arbitrary regular expression
@Past / @FutureDate/TimePast/future date and time

Using @Pattern and Its Limits

For simple regex matching, you can validate phone numbers or ZIP codes with @Pattern alone.

public class UserRequest {
    @Pattern(regexp = "^0\\d{9,10}$", message = "電話番号の形式が正しくありません")
    private String phoneNumber;
}

However, @Pattern has the following limits:

  • Reusing the same regex across multiple DTOs scatters string literals around
  • It’s hard to unify message keys, making internationalization tedious
  • You can’t write checks that reference external state such as “is there a duplicate in the DB?” or “does it match another field?”

When you hit these limits, that’s the signal to extract your own annotation with @Constraint and ConstraintValidator. In the following sections, we’ll look at three patterns in order: single field, DI-based, and cross-field.

Custom validation requires two components:

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

Spring Boot auto-configures LocalValidatorFactoryBean, and its internal SpringConstraintValidatorFactory handles DI automatically. As a result, field injection into the Validator class 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 checks phone number format. By giving it 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 spec. Adding a regexp attribute lets you 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. Designing it to leave null checks to @NotNull separates responsibilities cleanly.

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

DB Lookups via DI (Pattern 2: Email Duplication Check)

Spring Boot provides LocalValidatorFactoryBean via auto-configuration, and SpringConstraintValidatorFactory handles DI automatically, so field injection into your Validator class 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 more on JPA query methods, see this article as well.

For update scenarios where you want to “exclude my own email,” one approach is to add a long excludeId() default -1L attribute to the annotation and have the Validator branch its logic based on that ID.

Cross-Field Validation (Pattern 3: Password Confirmation)

To compare multiple fields, annotate the class itself. The key is to set @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() avoids an NPE even if either side 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 something like req.getPassword().equals(...) would throw an NPE in that case, so watch out.

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

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

Internationalizing Error Messages

Define keys and values in ValidationMessages.properties (directly under the classpath).

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

This is easily confused with Spring Boot’s messages.properties, but Bean Validation’s message interpolation reads from ValidationMessages.properties.

How to Write Tests

Validators that don’t require DI can be tested with just 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, need the Spring context. Writing them as integration tests with @SpringBootTest, or @WebMvcTest + MockMvc, is the realistic approach.

When to Move Logic to the Service Layer

Common Problems and Fixes

The Validator Isn’t Called

The most common stumbling block is forgetting to put @Valid (or @Validated) on the Controller argument.

@PostMapping("/users")
public ResponseEntity<?> create(@Valid @RequestBody UserRequest req) { ... }

If you leave off @Valid, the annotations on the fields like @PhoneNumber won’t be evaluated at all. If you want to trigger validation on Service method arguments, you need to put @Validated on the class. For details, see @Validated group and method validation.

Handling MethodArgumentNotValidException

When @Valid validation fails, MethodArgumentNotValidException is thrown; when @Validated method validation fails, ConstraintViolationException is thrown. To return a well-formatted error response to the client, catch them collectively with @RestControllerAdvice. Production-grade handling is explained in detail in Implementing GlobalExceptionHandler for Production.

Applying the Same Annotation Multiple Times to the Same Field (@Repeatable)

If you want to apply the same constraint multiple times with different groups, define a container annotation with @Repeatable.

@Repeatable(PhoneNumber.List.class)
public @interface PhoneNumber {
    // ... 既存の属性
    @Target({ElementType.FIELD})
    @Retention(RetentionPolicy.RUNTIME)
    @interface List {
        PhoneNumber[] value();
    }
}

This lets you stack @PhoneNumber(groups = Create.class) and @PhoneNumber(groups = Update.class) on the same field.

Field Injection in the Validator Is null

When a Validator is instantiated manually like new MyValidator() (for example, in a unit test using Validation.buildDefaultValidatorFactory()), DI doesn’t work. Validators that require DI should be verified by starting up @SpringBootTest or @WebMvcTest.

Should everything be annotation-based? Not really.

  • Cases suited to annotations: the same rule is reused across multiple DTOs, and it’s self-contained as an input check
  • Cases that should stay in the Service layer: tightly tangled with business logic, or requiring transaction management

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

Summary

Combining @Constraint and ConstraintValidator lets you put your own validation rules on top of the Bean Validation framework. Once you have the three patterns—single field, DB lookup, and cross-field—down, you can handle most real-world cases. Give it a try as a way to organize check logic that has scattered across your Service layer.