@NotBlank や @Pattern は便利ですが、「電話番号の形式チェック」「メールアドレスの重複確認」「パスワードと確認欄の一致」といったルールはカバーできません。よくある対応はService層に手書きすることですが、やがてControllerとServiceにバリデーションロジックが散乱してきます。
@Constraint と ConstraintValidator を使えば、これらを独自のアノテーションとして定義できます。本記事では3つの実装パターンを順番に解説します。
@Valid や @Validated の基本的な使い方は こちらの記事 を、グループバリデーションの使い方は こちらの記事 をご参照ください。
全体の仕組み
まず押さえたい標準バリデーションアノテーション
カスタムアノテーションを作る前に、Bean Validation が標準で提供するアノテーションを俯瞰しておくと、『どこまで標準で足りて、どこから自作が必要か』の判断がしやすくなります。
| アノテーション | 対象型 | 用途 |
|---|---|---|
@NotNull | 任意 | null を禁止 |
@NotBlank | String | null / 空文字 / 空白のみを禁止 |
@NotEmpty | String/Collection/Map/配列 | null / 空を禁止 |
@Size(min, max) | String/Collection 等 | 長さ・要素数の範囲指定 |
@Min / @Max | 数値 | 数値の下限・上限 |
@Email | String | RFC 準拠のメール形式 |
@Pattern(regexp = ...) | String | 任意の正規表現にマッチ |
@Past / @Future | 日付/時刻 | 過去/未来の日時 |
@Pattern の使い方と限界
単純な正規表現マッチであれば、@Pattern だけで電話番号やZIPコードの形式チェックが書けます。
public class UserRequest {
@Pattern(regexp = "^0\\d{9,10}$", message = "電話番号の形式が正しくありません")
private String phoneNumber;
}
ただし、@Pattern には次のような限界があります。
- 同じ正規表現を複数のDTOで使い回すと、文字列リテラルが散らばる
- メッセージキーを統一しづらく、国際化が面倒になる
- 『DBに重複がないか』『他フィールドと一致するか』といった 外部状態を参照するチェック は書けない
こうした限界に当たったタイミングが、@Constraint と ConstraintValidator で自作アノテーションを切り出す合図です。以降のセクションで、単一フィールド・DI利用・クロスフィールドの3パターンを順に見ていきます。
カスタムバリデーションには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層への移行判断
よくあるトラブルと対処
Validator が呼ばれない
最も多いつまずきは、Controller の引数に @Valid(または @Validated)を付け忘れているケースです。
@PostMapping("/users")
public ResponseEntity<?> create(@Valid @RequestBody UserRequest req) { ... }
@Valid を外すと、フィールドに付けた @PhoneNumber などのアノテーションは 一切評価されません。Service メソッドの引数で起動したい場合はクラスに @Validated を付ける必要があります。詳しくは @Validatedのグループ・メソッドバリデーション を参照してください。
MethodArgumentNotValidException のハンドリング
@Valid での検証失敗時には MethodArgumentNotValidException が、@Validated のメソッドバリデーション失敗時には ConstraintViolationException がスローされます。クライアントに整形済みのエラーレスポンスを返すには @RestControllerAdvice でまとめて捕捉します。本番運用向けのハンドリングは GlobalExceptionHandlerを本番運用向けに実装する で詳しく解説しています。
同じフィールドに同じアノテーションを複数付けたい(@Repeatable)
グループ違いで同じ制約を複数回付けたい場合は、@Repeatable でコンテナアノテーションを定義します。
@Repeatable(PhoneNumber.List.class)
public @interface PhoneNumber {
// ... 既存の属性
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@interface List {
PhoneNumber[] value();
}
}
これで @PhoneNumber(groups = Create.class) と @PhoneNumber(groups = Update.class) を同じフィールドに重ね付けできます。
Validator のフィールドインジェクトが null になる
new MyValidator() のように手動インスタンス化された経路(例: 単体テストで Validation.buildDefaultValidatorFactory() を使う)では DI が効きません。DI が必要な Validator は @SpringBootTest または @WebMvcTest で起動して検証してください。
すべてをアノテーション化すべきかというと、そうではありません。
- アノテーション化に向くケース: 同じルールを複数のDTOで使い回せる、入力チェックとして完結している
- Service層に残すべきケース: ビジネスロジックと複雑に絡み合っている、トランザクション管理が必要
新規に作るDTOから適用していくと、既存コードへの影響を最小限にしながら段階的に整理できます。例外ハンドリングの整備については こちらの記事 も参考にしてください。
まとめ
@Constraint と ConstraintValidator を組み合わせると、独自のバリデーションルールをBeanValidationの仕組みに乗せられます。単一フィールド、DB問い合わせ、クロスフィールドの3パターンを押さえれば実務のほとんどに対応できます。Service層に散らばっていたチェックロジックを整理する手段として、ぜひ試してみてください。