@NotBlank や @Pattern は便利ですが、「電話番号の形式チェック」「メールアドレスの重複確認」「パスワードと確認欄の一致」といったルールはカバーできません。よくある対応はService層に手書きすることですが、やがてControllerとServiceにバリデーションロジックが散乱してきます。
@Constraint と ConstraintValidator を使えば、これらを独自のアノテーションとして定義できます。本記事では3つの実装パターンを順番に解説します。
@Valid や @Validated の基本的な使い方は こちらの記事 を、グループバリデーションの使い方は こちらの記事 をご参照ください。
全体の仕組み
カスタムバリデーションには2つの部品が必要です。
- アノテーション定義:
@Constraint(validatedBy = ...)を付けた@interface - Validatorクラス:
ConstraintValidator<アノテーション, 検証対象の型>を実装したクラス
Spring Bootは LocalValidatorFactoryBean をAutoConfigureしており、内部の SpringConstraintValidatorFactory がDIを自動処理します。そのため、Validatorクラスへのフィールドインジェクトはそのまま動作します。
単一フィールドのバリデーション(パターン1・電話番号)
まずはシンプルな例です。電話番号形式をチェックする @PhoneNumber アノテーションを作ります。regexp 属性を持たせることで、利用側がパターンをカスタマイズできるようにしています。
@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}$"; // デフォルトはモバイル向け簡易パターン
}
message / groups / payload の3属性はBeanValidation仕様で 必須 です。regexp 属性を追加すると、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();
}
}
null のときに true を返すのが慣例です。nullチェックは @NotNull と組み合わせる設計にすると責務が分かれてすっきりします。
public class UserRequest {
@NotNull
@PhoneNumber
private String phoneNumber;
// ...
}
DIを使ったDB問い合わせ(パターン2・メール重複チェック)
Spring BootはAutoConfigureで LocalValidatorFactoryBean を用意しており、SpringConstraintValidatorFactory がDIを自動処理するため、Validatorクラスへのフィールドインジェクトはそのまま動作します。@Component は不要です。
@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);
}
}
JPAのクエリメソッドについては こちらの記事 も参考にしてください。
更新時に「自分自身のメールは除外したい」ケースには、アノテーションに long excludeId() default -1L 属性を追加して、Validator側でIDを受け取って判定を分岐させるアプローチがとれます。
クロスフィールドバリデーション(パターン3・パスワード確認)
複数フィールドを比較するにはクラスにアノテーションを付けます。@Target を 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;
}
}
Objects.equals() を使うことでどちらかが null でも NPE が発生しません。Bean Validationはフィールドレベルとクラスレベルの制約を並行評価するため、@NotBlank が付いていても isValid() に null が渡り得ます。req.getPassword().equals(...) のような呼び方はこのケースで NPE になるので注意してください。
@PasswordMatch
public class PasswordResetRequest {
@NotBlank
private String password;
@NotBlank
private String confirmPassword;
// getter/setter...
}
エラーメッセージの国際化
ValidationMessages.properties(クラスパス直下)にキーと値を定義します。
validation.phone.invalid=電話番号の形式が正しくありません(例:09012345678)
validation.email.duplicate=このメールアドレスはすでに登録されています
validation.password.mismatch=パスワードと確認用パスワードが一致しません
Spring Bootの messages.properties と混同しがちですが、BeanValidationのメッセージ補間は ValidationMessages.properties から読み込まれます。
テストの書き方
DIが不要なValidatorは 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();
}
UniqueEmailValidator のようにRepositoryをインジェクトするValidatorはSpringコンテキストが必要です。@SpringBootTest または @WebMvcTest + MockMvcを使った統合テストとして書くのが現実的です。
Service層への移行判断
すべてをアノテーション化すべきかというと、そうではありません。
- アノテーション化に向くケース: 同じルールを複数のDTOで使い回せる、入力チェックとして完結している
- Service層に残すべきケース: ビジネスロジックと複雑に絡み合っている、トランザクション管理が必要
新規に作るDTOから適用していくと、既存コードへの影響を最小限にしながら段階的に整理できます。例外ハンドリングの整備については こちらの記事 も参考にしてください。
まとめ
@Constraint と ConstraintValidator を組み合わせると、独自のバリデーションルールをBeanValidationの仕組みに乗せられます。単一フィールド、DB問い合わせ、クロスフィールドの3パターンを押さえれば実務のほとんどに対応できます。Service層に散らばっていたチェックロジックを整理する手段として、ぜひ試してみてください。