@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
@interfaceannotated 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.