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

@ConstraintConstraintValidator を使えば、これらを独自のアノテーションとして定義できます。本記事では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・パスワード確認)

複数フィールドを比較するにはクラスにアノテーションを付けます。@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層への移行判断

すべてをアノテーション化すべきかというと、そうではありません。

  • アノテーション化に向くケース: 同じルールを複数のDTOで使い回せる、入力チェックとして完結している
  • Service層に残すべきケース: ビジネスロジックと複雑に絡み合っている、トランザクション管理が必要

新規に作るDTOから適用していくと、既存コードへの影響を最小限にしながら段階的に整理できます。例外ハンドリングの整備については こちらの記事 も参考にしてください。

まとめ

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