@NotBlank@Pattern は便利ですが、「電話番号の形式チェック」「メールアドレスの重複確認」「パスワードと確認欄の一致」といったルールはカバーできません。よくある対応はService層に手書きすることですが、やがてControllerとServiceにバリデーションロジックが散乱してきます。

@ConstraintConstraintValidator を使えば、これらを独自のアノテーションとして定義できます。本記事では3つの実装パターンを順番に解説します。

@Valid@Validated の基本的な使い方は こちらの記事 を、グループバリデーションの使い方は こちらの記事 をご参照ください。

全体の仕組み

まず押さえたい標準バリデーションアノテーション

カスタムアノテーションを作る前に、Bean Validation が標準で提供するアノテーションを俯瞰しておくと、『どこまで標準で足りて、どこから自作が必要か』の判断がしやすくなります。

アノテーション対象型用途
@NotNull任意null を禁止
@NotBlankStringnull / 空文字 / 空白のみを禁止
@NotEmptyString/Collection/Map/配列null / 空を禁止
@Size(min, max)String/Collection 等長さ・要素数の範囲指定
@Min / @Max数値数値の下限・上限
@EmailStringRFC 準拠のメール形式
@Pattern(regexp = ...)String任意の正規表現にマッチ
@Past / @Future日付/時刻過去/未来の日時

@Pattern の使い方と限界

単純な正規表現マッチであれば、@Pattern だけで電話番号やZIPコードの形式チェックが書けます。

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

ただし、@Pattern には次のような限界があります。

  • 同じ正規表現を複数のDTOで使い回すと、文字列リテラルが散らばる
  • メッセージキーを統一しづらく、国際化が面倒になる
  • 『DBに重複がないか』『他フィールドと一致するか』といった 外部状態を参照するチェック は書けない

こうした限界に当たったタイミングが、@ConstraintConstraintValidator で自作アノテーションを切り出す合図です。以降のセクションで、単一フィールド・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・パスワード確認)

複数フィールドを比較するにはクラスにアノテーションを付けます。@TargetElementType.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から適用していくと、既存コードへの影響を最小限にしながら段階的に整理できます。例外ハンドリングの整備については こちらの記事 も参考にしてください。

まとめ

@ConstraintConstraintValidator を組み合わせると、独自のバリデーションルールをBeanValidationの仕組みに乗せられます。単一フィールド、DB問い合わせ、クロスフィールドの3パターンを押さえれば実務のほとんどに対応できます。Service層に散らばっていたチェックロジックを整理する手段として、ぜひ試してみてください。