@Valid @RequestBody List<UserDto> と書いたのに要素の @NotBlank が効かない」「@RequestParam@Min(1) を付けたのに page=0 が通る」「@Validated を付けたら 500 になった」。@Valid の基本を覚えた次に、ほぼ全員が踏むつまずきですよね。

この記事はその「次」だけに絞った逆引きです。List の要素検証、クエリパラメータやパス変数の検証、そして書き方によって飛ぶ例外が 3 種類に分かれる問題を、修正コードと一緒に整理します。@Valid そのものの使い方は @Validの使い方の記事、制約アノテーションの一覧は バリデーションアノテーション早見表 に任せます。

前提バージョンと検証環境

前提は Spring Boot 3.2 以上、Jakarta Bean Validation 3.0、Hibernate Validator 8 系の Spring MVC(Servlet スタック)です。WebFlux は扱いません。

Boot と Spring Framework の対応は、Boot 3.2 / 3.3 が Spring Framework 6.1、Boot 3.4 / 3.5 が 6.2 です。以降の版表記は Boot 基準に揃えます。Boot 3.2 でコントローラーの組み込みメソッド検証が入り、ここが挙動の分岐点になります。

この記事の挙動の説明は、Spring Framework リファレンスの「Spring MVC Validation」と HandlerMethod#shouldValidateArguments の Javadoc の記述に基づいています。手元で Boot の各バージョンを立てて実測した結果ではないので、迷ったら本文中の書き方をそのまま使い、リファレンスを一次資料として確認してください。

まず依存を確認しておきましょう。Boot 2.3 以降、spring-boot-starter-web には validation が含まれていません。これがないと、この記事のどの修正をしても何も起きません。

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'

症状1: @Valid @RequestBody List<UserDto> で要素の検証が効かない

一括登録 API でよくある形です。

public record UserDto(
        @NotBlank String name,
        @Email String email) {}

@RestController
@RequestMapping("/users")
public class UserController {

    // 単体ならこれで 400 になる
    @PostMapping
    public void create(@Valid @RequestBody UserDto user) { /* ... */ }

    // List にすると要素の @NotBlank が検証されない
    @PostMapping("/bulk")
    public void bulk(@Valid @RequestBody List<UserDto> users) { /* ... */ }
}

name が空の要素を混ぜて送っても、そのまま 200 で通ります。単体の create なら 400 になる同じ UserDto が、List になった途端に検証されません。これは Boot 3.1 以前でも 3.2 以降でも同じです。

curl -i -X POST localhost:8080/users/bulk \
  -H 'Content-Type: application/json' \
  -d '[{"name":"alice","email":"[email protected]"},{"name":"","email":"[email protected]"}]'
# HTTP/1.1 200

なぜ効かないのか

Spring MVC の検証には 2 つの経路があります。1 つは引数リゾルバによる個別検証で、@Valid 付きの @RequestBodyDataBinder 経由で検証し、失敗すると MethodArgumentNotValidException になります。ただしリファレンスには、対象は「コマンドオブジェクトであって、MapCollection のようなコンテナではない場合」と明記されています。引数が List だとこの経路には乗りません。

もう 1 つが Boot 3.2 で入った組み込みメソッド検証で、失敗すると HandlerMethodValidationException になります。こちらの起動条件は、引数に @Min@NotEmpty などの 制約アノテーション が直接付いていることです。HandlerMethod#shouldValidateArguments の Javadoc にあるとおり、@Valid は制約ではなくネストした制約へのカスケード指示なので、@Valid だけではメソッド検証は起動しません。

つまり @Valid @RequestBody List<UserDto> は、コンテナなので個別検証から外れ、制約がないのでメソッド検証も起動しない、という二重の隙間に落ちています。DTO のフィールドに @Valid List<Item> items と書けば要素まで降りるのは、DTO 自体がコマンドオブジェクトとして個別検証に乗り、そのフィールドからカスケードするからです。

修正A: 引数に制約を付けて List<@Valid UserDto> にする

JSON の形を変えたくない場合はこちらです。役割を分けて考えると迷いません。

  • 引数に直接付ける @NotEmpty@NotNull などの制約が、組み込みメソッド検証の 起動条件 です。@Valid は起動条件になりません
  • 型引数の <@Valid UserDto> は、要素へカスケードする 明示的な指定 です(Bean Validation 2.0 のコンテナ要素制約)
// 空リストも禁止する場合
@PostMapping("/bulk")
public void bulk(@NotEmpty @RequestBody List<@Valid UserDto> users) { /* ... */ }

// 空リストは許容し、null だけ拒否する場合
@PostMapping("/bulk")
public void bulk(@NotNull @RequestBody List<@Valid UserDto> users) { /* ... */ }

リファレンスにも「@NotNull は制約なので、@Valid 引数に足すとメソッド検証になる」と書かれています。@NotEmpty は List 自体への制約で「空リスト禁止」、型引数の @Valid は要素の中身の検証です。別物なので、両方必要なら両方書きます。

補足として、引数レベルに @Valid を付ければ Bean Validation の従来のカスケードで要素まで検証されるので、型引数側の @Valid は厳密には省けます。ここでは「要素を検証したい」という意図を明示するために付けています。

Boot 3.2 以降ならこれだけで、要素の @NotBlank 違反が HandlerMethodValidationException として 400 になります。Boot 3.1 以前には組み込み検証がないので、Controller クラスに @Validated を付けて AOP のメソッド検証を有効にしてください。この場合は ConstraintViolationException が飛びます。

@Validated  // Boot 3.1 以前ではこれが必須
@RestController
@RequestMapping("/users")
public class UserController { /* ... */ }

修正B: ラッパー DTO で受け取る

JSON の形を変えられるなら、こちらのほうが単純で予測しやすいです。修正A と同じ /bulk に書いていますが、実際にはどちらか一方を採用してください。

public record UsersRequest(@Valid @NotEmpty List<UserDto> users) {}

@PostMapping("/bulk")
public void bulk(@Valid @RequestBody UsersRequest request) { /* ... */ }

これは普通の @RequestBody 検証なので、失敗すると Boot 3.x のどの版でも MethodArgumentNotValidException(400)になります。既に @RestControllerAdvice でこの例外を処理していれば、そのまま流用できます。JSON が {"users": [...]} の形に変わるのがトレードオフですが、後からメタ情報を足せる余地にもなります。

修正Aと修正Bの比較

修正A @NotEmpty List<@Valid Dto>修正B ラッパー DTO
JSON の形変わらない(配列のまま){"users": [...]} に変わる
発生する例外HandlerMethodValidationException(3.2+、@Validated なし)/ ConstraintViolationException(クラス @Validated 時)MethodArgumentNotValidException
エラーのパス(3.2+)ParameterValidationResult の引数名とインデックスから組み立てるBindingResult がそのまま users[1].name
エラーのパス(@Validated 時)PropertyPath bulk.users[1].name の先頭を削る同上
既存ハンドラの流用追加実装が必要そのまま使える
向いている場面既存 API の互換維持新規 API

新しく作る API ならラッパー DTO を推奨します。既存 API の互換を守る必要があるときだけ修正A、という判断でだいたい困りません。

症状2: @RequestParam / @PathVariable@Min@Pattern が効かない

@GetMapping
public List<UserDto> list(@RequestParam @Min(1) int page) { /* ... */ }

@GetMapping("/{id}")
public UserDto get(@PathVariable @Pattern(regexp = "[0-9]+") String id) { /* ... */ }

@RequestParam@PathVariable は、@RequestBody のような引数リゾルバの個別検証の対象ではありません。そのため Boot 3.1 以前では page=0 が 200 で通ります。修正はクラスに @Validated を付けることです。メソッドではなく クラスに付ける のがポイントです。

Boot 3.2 以降なら、引数に制約アノテーションがあることが起動条件を満たすので、@Validated なしで組み込みメソッド検証が働き、HandlerMethodValidationException で 400 になります。ただし既存の MethodArgumentNotValidException ハンドラは通らないので、Boot デフォルトのエラー JSON が返ります。

注意したいのは Boot 3.2 以降で @Validated を付けたままにした場合です。リファレンスには「クラスに @Validated があれば AOP プロキシ経由のメソッド検証になり、組み込み検証を使うには外す必要がある」とあります。つまり例外は ConstraintViolationException のままです。パラメータの受け取り方自体は リクエストパラメータの受け取り方の記事 を参照してください。

発生する3つの例外の対応表

書き方によって飛ぶ例外が変わるので、ここを押さえないとハンドラが反応しなくなります。

例外発生条件バージョンデフォルト HTTPエラー情報の取り出し方
MethodArgumentNotValidException@Valid @RequestBody の DTO(非コンテナ)検証失敗Boot 3.x 全体400getBindingResult().getFieldErrors()
ConstraintViolationExceptionクラスの @Validated による AOP メソッド検証の失敗Boot 3.x 全体500getConstraintViolations()getPropertyPath()
HandlerMethodValidationException@Validated なしで、引数に制約が付いた組み込みメソッド検証の失敗Boot 3.2 以上400getParameterValidationResults()(Boot 3.4+)/ getAllValidationResults()(Boot 3.2 / 3.3)

最大の落とし穴は ConstraintViolationException です。Spring MVC はこの例外を知らないので、ハンドラがなければそのまま 500 になります。

もう 1 つ、Boot 3.2 へ上げて @Validated を外すと例外が ConstraintViolationException から HandlerMethodValidationException に変わります。既存のハンドラが反応しなくなるので、アップグレード時は要確認です。また、リファレンスによるとメソッド検証は個別検証に優先します。同じメソッドに @Min 付きの @RequestParam@Valid @RequestBody が同居すると、@RequestBody 側の失敗も HandlerMethodValidationException になります。

@RestControllerAdvice で3例外を統一エラーレスポンスにする

3 つとも同じ 400、同じ JSON に揃えます。ResponseEntityExceptionHandler を継承すると 2 つはオーバーライドで済み、ConstraintViolationException だけ @ExceptionHandler を足す形になります。エラー項目のレコードは FieldViolation という名前にしています。FieldError にすると Spring の org.springframework.validation.FieldError と衝突して、IDE の自動 import で曖昧参照になるためです。

public record ErrorResponse(String message, List<FieldViolation> errors) {}
public record FieldViolation(String field, String message) {}

@RestControllerAdvice
public class ValidationExceptionHandler extends ResponseEntityExceptionHandler {

    // @Valid @RequestBody の失敗(ラッパー DTO 方式)
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
        List<FieldViolation> errors = ex.getBindingResult().getFieldErrors().stream()
                .map(e -> new FieldViolation(e.getField(), e.getDefaultMessage()))
                .toList();
        return ResponseEntity.badRequest().body(new ErrorResponse("Validation failed", errors));
    }

    // 組み込みメソッド検証の失敗(Boot 3.2+、@Validated なし)
    @Override
    protected ResponseEntity<Object> handleHandlerMethodValidationException(
            HandlerMethodValidationException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
        List<FieldViolation> errors = new ArrayList<>();
        // getParameterValidationResults() は Boot 3.4(Spring Framework 6.2)以降。
        // Boot 3.2 / 3.3 では getAllValidationResults() に読み替える。
        // getContainerIndex() にインデックスが入るのは Boot 3.2.2(Spring Framework 6.1.3)以降。
        for (ParameterValidationResult result : ex.getParameterValidationResults()) {
            String param = result.getMethodParameter().getParameterName();
            Integer index = result.getContainerIndex();
            String prefix = index != null ? param + "[" + index + "]" : param;
            if (result instanceof ParameterErrors paramErrors) {
                // List<@Valid Dto> の要素エラー。users[1].name の形にする
                paramErrors.getFieldErrors().forEach(e ->
                        errors.add(new FieldViolation(prefix + "." + e.getField(), e.getDefaultMessage())));
            } else {
                // @Min 付き @RequestParam などの単純な引数エラー
                result.getResolvableErrors().forEach(e ->
                        errors.add(new FieldViolation(prefix, e.getDefaultMessage())));
            }
        }
        return ResponseEntity.badRequest().body(new ErrorResponse("Validation failed", errors));
    }

    // AOP メソッド検証の失敗(@Validated 時)。これがないと 500 になる
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException ex) {
        List<FieldViolation> errors = ex.getConstraintViolations().stream()
                .map(v -> new FieldViolation(stripMethodName(v.getPropertyPath()), v.getMessage()))
                .toList();
        return ResponseEntity.badRequest().body(new ErrorResponse("Validation failed", errors));
    }

    // bulk.users[1].name -> users[1].name
    private static String stripMethodName(Path path) {
        String s = path.toString();
        return s.substring(s.indexOf('.') + 1);
    }
}

getParameterValidationResults() は Boot 3.4(Spring Framework 6.2)以降の名前で、Boot 3.2 / 3.3 では getAllValidationResults() です。3.4 以降でも旧名は非推奨として残っていますが、将来のバージョンで削除予定なので新名に寄せておきましょう。

引数名を取るには -parameters 付きのコンパイルが必要です。これは HandlerMethodValidationException 側だけでなく、ConstraintViolationExceptionPropertyPath にも効きます。-parameters なしだと bulk.users[1].namebulk.arg0[1].name になり、先頭を削っても arg0[1].name のままです。Boot の Gradle / Maven プラグインならデフォルトで有効なので、独自のビルド設定にしている場合だけ確認してください。

このハンドラを通せば、どの方式でも「何番目の要素が失敗したか」がクライアントに伝わります。ラッパー DTO 方式は BindingResult の field が最初から users[1].name で、修正A + Boot 3.2 以上は getContainerIndex() のインデックスで組み立て、修正A + @ValidatedPropertyPath の先頭ノードを削って同じ形にします。

{
  "message": "Validation failed",
  "errors": [
    { "field": "users[1].name", "message": "must not be blank" }
  ]
}

インデックスは 0 始まりなので、users[1] は 2 番目の要素です。@ControllerAdvice の使い方全般は 例外ハンドリングの記事、レスポンスを RFC 9457 形式にしたい場合は ProblemDetail の記事 を参照してください。

Controller に @Validated を付ける副作用

@Validated を付けるとクラスが CGLIB プロキシになります。そのため final クラスや final メソッドではプロキシ化できず、検証されないか起動時にエラーになります。同じ Controller 内で this.method() と自己呼び出しした場合もプロキシを経由しないので検証されません。

Boot 3.2 以上では @Validated があると組み込み検証が動かず、例外が ConstraintViolationException のままになることは先に述べた通りです。Boot 3.2 以上なら Controller の @Validated は外して、例外を HandlerMethodValidationException に一本化するのが簡潔です。Service 層のメソッド検証は引き続き @Validated の出番で、こちらは @Validatedの記事 の主題です。

MockMvc で要素検証をテストする

修正が効いていることをテストで固めておきましょう。@WebMvcTest@RestControllerAdvice も読み込みます。1 本目は 修正B(ラッパー DTO)の Controller を対象にしたテストです。修正A を採用した場合は、ボディを配列そのもの([{...}, {...}])に差し替えてください。期待する field は同じく users[1].name になります。

@WebMvcTest(UserController.class)
class UserControllerValidationTest {

    @Autowired MockMvc mockMvc;

    @Test
    void 要素のnameが空なら400でインデックス付きのfieldが返る() throws Exception {
        mockMvc.perform(post("/users/bulk")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"users": [
                                  {"name": "alice", "email": "[email protected]"},
                                  {"name": "", "email": "[email protected]"}
                                ]}
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[0].field").value("users[1].name"));
    }

    @Test
    void pageが0なら400になる() throws Exception {
        // ConstraintViolationException が 500 になっていないかの検知にもなる
        mockMvc.perform(get("/users").param("page", "0"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[0].field").value("page"));
    }
}

テストの書き方全般は JUnit と Mockito のテストガイド にまとめています。

それでも効かないときの確認項目

ここまでやっても効かない場合は、上から順に潰してみてください。

  • spring-boot-starter-validation の依存が抜けている(web starter には含まれません)
  • jakarta.validationjavax.validation が混在している(Boot 3.x では jakarta のみ有効)
  • @Valid@Validated を付け間違えている(@RequestBody には @Valid、クラスには @ValidatedList<@Validated Dto> はコンパイルできません)
  • List 引数に @Valid しか付いておらず、@NotEmpty@NotNull などの制約がないためメソッド検証が起動していない
  • ネストした DTO のフィールドに @Valid を付け忘れてカスケードしていない
  • ConstraintViolationException のハンドラがなく 500 になっている
  • @RestControllerAdvicebasePackages や順序の問題で拾えていない

自作の制約アノテーションが絡む場合は カスタムバリデーションの記事 も確認してみてください。

まとめ

List の要素検証は、@NotEmpty @RequestBody List<@Valid Dto> のように引数へ制約を付けて起動させるか、ラッパー DTO で受けるかのどちらかで、新規 API ならラッパー DTO が扱いやすいです。@RequestParam@PathVariable の制約は Boot 3.2 以上なら付けるだけで効き、3.1 以前はクラスに @Validated を付けます。

例外は 3 種類あり、ConstraintViolationException だけがデフォルト 500 なので、必ずハンドラを用意しましょう。3 つを同じ形に揃えておけば、書き方やバージョンが変わってもクライアントから見える挙動は変わりません。