@Validated でグループ指定までは書けたのに、名前が空でメールも不正な入力に対して、部署IDの存在チェックがしっかりDBに問い合わせに行ってしまう。しかもレスポンスには形式エラーと整合性エラーが混ざって返ってくる。そんな経験はありませんか?

原因はシンプルで、Bean Validationのグループは既定では 順序が保証されず、指定した全グループが実行される からです。

この記事では、@Validatedの記事 で紹介したインターフェース型の @GroupSequence から一歩進めて、DTOクラスに付けてDefaultグループを再定義するときの落とし穴、Hibernate Validator固有の @GroupSequenceProvider で状態に応じてグループを切り替える実装、REST APIでの段階的なエラーレスポンス、そして「後段の制約が呼ばれていない」ことを保証するMockMvcテストまで扱います。前提はSpring Boot 3.x + spring-boot-starter-validation(Hibernate Validator 8系)です。

グループを複数渡しても順序は制御できない

まず課題を再現しましょう。形式チェック用の Format と、DB存在チェック用の Persistence の2グループを持つDTOです。

public interface Format {}
public interface Persistence {}

public record UserRequest(
        @NotBlank(groups = Format.class) String name,
        @Email(groups = Format.class) String email,
        @NotNull(groups = Format.class)
        @ExistingDepartment(groups = Persistence.class)
        Long departmentId
) {}

@ExistingDepartment はRepositoryを呼ぶカスタム制約です。呼ばれたことが分かるようにログを仕込んでおきます。

public class ExistingDepartmentValidator
        implements ConstraintValidator<ExistingDepartment, Long> {

    private static final Logger log = LoggerFactory.getLogger(ExistingDepartmentValidator.class);
    private final DepartmentRepository repository;

    public ExistingDepartmentValidator(DepartmentRepository repository) {
        this.repository = repository;
    }

    @Override
    public boolean isValid(Long id, ConstraintValidatorContext ctx) {
        if (id == null) return true;
        log.info("existsById called: {}", id);
        return repository.existsById(id);
    }
}

制約アノテーション側の定義やRepository注入の仕組みは カスタムバリデーションアノテーションの記事 で解説しているので、ここでは省略します。

Controllerの引数を @RequestBody @Validated({Format.class, Persistence.class}) UserRequest request として、わざと形式エラーのある入力を送ってみます。

INFO  ExistingDepartmentValidator : existsById called: 999
WARN  DefaultHandlerExceptionResolver : Validation failed ... with 3 errors

name が空で email も不正なのに、existsById が呼ばれています。仕様上、@Validated に複数グループを並べても実行順序は決まらず、すべてのグループの違反がまとめて返るのです。

問題は2つあります。無駄なDBアクセスが発生すること、そして形式エラーと整合性エラーが混ざった読みにくいレスポンスになることです。

@GroupSequenceでグループを直列化する

解決策が jakarta.validation.GroupSequence です。インターフェースに付けて、グループの並び順を宣言します。

@GroupSequence({Format.class, Persistence.class})
public interface OrderedChecks {}

Controllerの引数は @RequestBody @Validated(OrderedChecks.class) UserRequest request に変えます。インターフェース型シーケンスの基本的な書き方や、グループ未指定の制約を動かすために Default.class を並びに含める注意は @Validatedの記事の順序制御の節 にまとめているので、ここでは動作の違いに集中しましょう。

先ほどと同じ形式エラーありの入力を送った結果です。

WARN  DefaultHandlerExceptionResolver : Validation failed ... with 2 errors

existsById called の行が消え、エラーも形式の2件だけになりました。Format で違反が出た時点で Persistence は評価されない、つまり 前段が通るまで後段には進まない のがシーケンスの挙動です。

ひとつ補足すると、順序が保証されるのはグループ間だけで、同じグループ内の制約の順序は保証されません。「Aのチェックのあとに B」が必要なら、別グループに分けましょう。DBや外部APIが絡む重い制約ほど、後ろのグループに寄せるのが基本です。

ネストしたオブジェクトも、この形なら素直に扱えます。インターフェース型シーケンスでは、@Valid を付けたフィールドに 検証中のグループがそのまま伝播 します。ネスト先を別のグループで検証したいときは @ConvertGroup を併用します。

public record UserRequest(
        @NotBlank(groups = Format.class) String name,
        // 親が Persistence 段階に入ったとき、ネスト先は Default グループで検証される
        @Valid @ConvertGroup(from = Persistence.class, to = Default.class)
        AddressRequest address
) {}

Format 段階では AddressRequest の Format グループの制約が、親の形式チェックが通って Persistence 段階に進んだら AddressRequest のグループ未指定の制約が評価される、という動きになります。

クラスに付けてDefaultグループを再定義する

インターフェース型のシーケンスは便利ですが、Controllerの引数すべてに @Validated(OrderedChecks.class) を書いて回るのは地味に面倒ですよね。@Valid しか使えない場所もあります。

そんなときはDTOクラス自身に @GroupSequence を付けます。

@GroupSequence({UserRequest.class, Persistence.class})
public record UserRequest(
        @NotBlank String name,
        @Email String email,
        @NotNull
        @ExistingDepartment(groups = Persistence.class)
        Long departmentId
) {}

こうすると、このクラスの Default グループがシーケンスそのものに置き換わります。@NotBlank のようにグループ未指定の制約は UserRequest.class の位置で評価され、その後 Persistence へ進みます。Controller側は @RequestBody @Valid UserRequest request と書くだけで順序付き検証になります。

注意点が2つあります。

1つ目は、シーケンスに 自クラス(UserRequest.class)を必ず含める ことです。入れ忘れると GroupDefinitionException(HV000054、UserRequest must be part of the redefined default group sequence)になります。代わりに Default.class を書くこともできません。Default を再定義している最中にその Default 自体を並びに入れることは許されておらず、こちらは HV000053 になります。

やっかいなのは、この例外が 起動時には検出されない ことです。Hibernate ValidatorはそのクラスのBeanメタデータを初めて検証するときに構築するので、アプリは普通に起動し、そのDTOを最初に検証した時点で500になります。後述のMockMvcテストで該当DTOを1回は通しておきましょう。

2つ目は使い分けです。DTO固有の順序はクラスに、複数のDTOで共有する順序はインターフェースに、と決めておくと整理しやすいです。

ネストしたオブジェクトの扱いは、前節のインターフェース型シーケンスとは異なります。クラスでDefaultを再定義した場合、@Valid を付けたフィールドには親のシーケンスの各段階ではなく Default グループが伝播 し、ネスト先は自身の Default(再定義していればそのシーケンス)で検証されます。さらに、親のシーケンスが前段で止まってもネスト先の検証は実行されます。ネスト先の中でも順序を効かせたいなら、ネスト先のクラスにも同じように @GroupSequence を付けておきましょう。

@Valid による自動検証の基本は @Validの記事 を参照してください。

@GroupSequenceProviderで状態に応じてグループを選ぶ

静的な @GroupSequence では表現できないケースもあります。たとえば注文リクエストで、支払方法がカードならカード番号の制約を、銀行振込なら口座情報の制約を追加したい場合です。

これはHibernate Validator固有の機能で、アノテーションが org.hibernate.validator.group.GroupSequenceProvider、実装するインターフェースが org.hibernate.validator.spi.group.DefaultGroupSequenceProvider です。Jakarta Bean Validation標準ではありませんが、spring-boot-starter-validation にはHibernate Validatorが含まれているので追加の依存は不要です。

Providerの実装

import org.hibernate.validator.spi.group.DefaultGroupSequenceProvider;

public class OrderRequestSequenceProvider
        implements DefaultGroupSequenceProvider<OrderRequest> {

    @Override
    public List<Class<?>> getValidationGroups(OrderRequest order) {
        List<Class<?>> sequence = new ArrayList<>();
        sequence.add(OrderRequest.class); // 自クラスは必須

        // (1) メタデータ構築時は order 自体が null
        // (2) paymentMethod を省略した入力では order.paymentMethod() が null
        if (order == null || order.paymentMethod() == null) {
            return sequence;
        }
        switch (order.paymentMethod()) {
            case CARD -> sequence.add(CardChecks.class);
            case BANK_TRANSFER -> sequence.add(BankChecks.class);
        }
        sequence.add(Persistence.class);
        return sequence;
    }
}
import org.hibernate.validator.group.GroupSequenceProvider;

@GroupSequenceProvider(OrderRequestSequenceProvider.class)
public record OrderRequest(
        @NotNull PaymentMethod paymentMethod,
        @NotBlank(groups = CardChecks.class) String cardNumber,
        @NotBlank(groups = BankChecks.class) String bankAccount,
        @ExistingProduct(groups = Persistence.class) Long productId
) {}

押さえておきたい注意点

nullは 2段階 で考える必要があります。1つ目は引数の order 自体で、メタデータ構築時にnullで呼ばれます。2つ目は分岐に使うフィールドで、paymentMethod を省略したJSONが来ると order.paymentMethod() がnullになります。Providerは制約の評価より前に実オブジェクトを受け取って呼ばれるため、@NotNull を付けていても守ってくれません。後者のガードを忘れると switch が NullPointerException を起こし、本来400で返すべき入力に対して500が返ります。

返すリストには自クラスを必ず含めます。含めないとクラスへの @GroupSequence と同じく GroupDefinitionException(HV000054)です。また @GroupSequence とは同時に付けられません(HV000052)。Providerが呼ばれないときは、この併用か、Hibernate Validator以外のプロバイダを使っていないかを疑ってください。

Providerは引数なしコンストラクタで生成されるため、SpringのDIは効きません。Repositoryが必要なロジックは、これまで通り ConstraintValidator 側に置きましょう。

なお、Spring Boot 4.x(Hibernate Validator 9系)では getValidationGroups(Class<?>, T) が追加され、上記の1引数版は非推奨になっています。移行時はシグネチャを読み替えてください。

REST APIで段階的なエラーレスポンスを返す

順序付き検証ができたので、クライアントにも段階的にエラーを返しましょう。MethodArgumentNotValidException を @RestControllerAdvice で整形する最小実装です。

@RestControllerAdvice
public class ValidationExceptionHandler {

    record ErrorItem(String field, String code, String message) {}

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, List<ErrorItem>>> handle(MethodArgumentNotValidException ex) {
        BindingResult result = ex.getBindingResult();
        List<ErrorItem> errors = new ArrayList<>();
        result.getFieldErrors().forEach(e ->
                errors.add(new ErrorItem(e.getField(), e.getCode(), e.getDefaultMessage())));
        // クラスレベル制約(項目間の整合性など)はフィールドに紐付かないのでこちらに入る
        result.getGlobalErrors().forEach(e ->
                errors.add(new ErrorItem(null, e.getCode(), e.getDefaultMessage())));
        return ResponseEntity.badRequest().body(Map.of("errors", errors));
    }
}

getFieldErrors() だけ整形していると、クラスレベル制約の違反が黙って落ちるので、getGlobalErrors() も拾っておくのがポイントです。

同じエンドポイントに2回リクエストを投げてみます。1回目は形式エラーありです。

curl -s -X POST localhost:8080/users -H 'Content-Type: application/json' \
  -d '{"name":"","email":"bad","departmentId":999}'
# => {"errors":[{"field":"name","code":"NotBlank","message":"空白は許可されていません"},
#              {"field":"email","code":"Email","message":"電子メールアドレスとして正しくありません"}]}

departmentId の存在チェックは返っていません。形式を直して2回目を投げます。

curl -s -X POST localhost:8080/users -H 'Content-Type: application/json' \
  -d '{"name":"Taro","email":"[email protected]","departmentId":999}'
# => {"errors":[{"field":"departmentId","code":"ExistingDepartment","message":"存在しない部署です"}]}

標準制約のメッセージは Accept-Language(未指定ならJVMの既定ロケール)で切り替わるので、英語環境では must not be blank のように返ります。

クライアントはまず形式を直し、その後で整合性エラーに対処できます。サーバ側は無駄なDBアクセスをしません。

段階ごとにステータスを変えたい(形式は400、存在チェックは422など)場合は、FieldError を unwrap(ConstraintViolation.class) して getConstraintDescriptor().getGroups() を見れば、どのグループで止まったか判定できます。Service層のメソッドバリデーションでもシーケンスは有効ですが、例外は ConstraintViolationException になるのでハンドラが別になります。エラーレスポンス設計全般は REST APIの例外ハンドリングの記事 にまとめています。

MockMvcで「後段の制約が呼ばれていない」ことをテストする

順序制御は、誰かが @Validated の引数を書き換えた瞬間に静かに壊れます。verify(never()) までテストに書いておきましょう。

@WebMvcTest(UserController.class)
class UserControllerValidationTest {

    @Autowired MockMvc mockMvc;
    @MockitoBean DepartmentRepository repository; // Boot 3.3以前は @MockBean

    @Test
    void 形式エラーがあれば存在チェックは呼ばれない() throws Exception {
        mockMvc.perform(post("/users").contentType(APPLICATION_JSON)
                        .content("""
                                {"name":"","email":"bad","departmentId":999}"""))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[*].field", containsInAnyOrder("name", "email")));

        verify(repository, never()).existsById(any());
    }

    @Test
    void 形式がOKなら存在チェックが1回呼ばれる() throws Exception {
        when(repository.existsById(999L)).thenReturn(false);

        mockMvc.perform(post("/users").contentType(APPLICATION_JSON)
                        .content("""
                                {"name":"Taro","email":"[email protected]","departmentId":999}"""))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[0].field").value("departmentId"));

        verify(repository).existsById(999L);
    }
}

これに「すべて正しい入力で201になる」テストを1本足しておけば、回帰も防げます。クラスへの @GroupSequence を書き間違えたときの GroupDefinitionException(HV000053 / HV000054)も初回検証時に出るものなので、該当DTOを通すテストが1本あれば起動確認に頼らず検出できます。

@GroupSequenceProvider の分岐は、@ParameterizedTest と @CsvSource で paymentMethod を CARD と BANK_TRANSFER に切り替え、cardNumber や bankAccount のエラーが期待通りに出る・出ないことを jsonPath で確認すれば十分です。paymentMethod を省略したJSONで400が返るケースも、nullガードの回帰防止として入れておくと安心です。

@WebMvcTest の基本構成は MockMvcでControllerをテストする記事 を、例外ハンドラ側のテストは @RestControllerAdviceのテスト記事 を参考にしてください。

まとめ

グループの実行順序を制御する方法を3つ見てきました。

複数のDTOで共有する固定の順序ならインターフェース型の @GroupSequence を作り、@Validated(OrderedChecks.class) で使います。DTO固有の順序を @Valid でも効かせたいなら、クラスに @GroupSequence を付けてDefaultを再定義し、自クラスを忘れずに含めます。ネスト先には Default が伝播する点だけ覚えておきましょう。値に応じてグループを切り替えたいなら @GroupSequenceProvider ですが、Hibernate Validator依存・2段階のnull考慮・自クラス必須の3点は押さえておきたいところです。

どのパターンでも、DBや外部APIが絡む重い制約は後段グループに寄せ、MockMvcで verify(never()) まで書いておくと安心です。順序の崩れもシーケンス定義のミスも目に見えにくいので、テストで守っておきましょう。