注文DTOの中に子オブジェクトと明細のリストが入る階層図。子の入力まで検証する

実装・エラー解決

Spring BootでネストしたDTOのバリデーションが効かない原因|Listの中まで検証する方法

注文APIで、注文者名が空なら400になるのに、明細の品番が空でも保存される。コントローラー引数に @Valid があるのを確認しても、原因が見えないことがあります。

私なら最初に見るのは、親から子へ検証を進める指定があるかです。親の検証は、子の中身を自動で全部たどるわけではありません。注文と複数明細を例に、検証する階層を見える形にします。以下は Java 21、Spring Boot 3.5系と spring-boot-starter-validation の例です。

症状を一つのJSONで再現する

{
  "customerName": "田中",
  "items": [
    {"sku": "A-001", "quantity": 2},
    {"sku": "", "quantity": 0}
  ]
}

この注文では、2件目の品番と数量が不正です。items 自体が存在することと、各明細の項目が正しいことは別の検証です。@NotEmpty をListに付けても、それだけでは明細の sku を調べません。

親、List要素、子をそれぞれ指定する

入れ子のDTOで検証が進む経路。リクエスト:注文DTOを受け取る。子オブジェクト:@Validで検証を伝播。List要素:各明細の制約を見る。結果:項目の位置を特定する
入れ子のDTOで検証が進む経路。@Validが効く経路をたどる
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;

public record OrderRequest(
        @NotBlank String customerName,
        @NotEmpty List<@NotNull @Valid OrderItemRequest> items
) {}

public record OrderItemRequest(
        @NotBlank String sku,
        @Min(1) int quantity
) {}

ここでは三か所に別々の意味があります。

指定検証する内容
@NotEmptyListが null または空ではないか
@NotNullList内の各要素が null ではないか
@Valid各 OrderItemRequest の制約へ進むか

Hibernate Validator 8では、List<@Valid OrderItemRequest> のように要素の型に @Valid を付ける書き方が意図を示しやすい形です。昔の @Valid List<OrderItemRequest> でも動く構成がありますが、これから書くなら何をたどるかが明確な位置へ付けます。

入口にも @Valid が必要です。

import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
class OrderController {
    @PostMapping("/orders")
    ResponseEntity<Void> create(@Valid @RequestBody OrderRequest request) {
        // 実際の保存処理を呼ぶ
        return ResponseEntity.noContent().build();
    }
}

この例で @Valid を引数から外すと、DTO内のアノテーションがあってもこの入口では検証されません。また、jakarta.validation.Valid と jakarta.validation.constraints.* を使います。Spring Boot 3系のアプリへ旧 javax.validation の例をそのまま貼り付けると、世代が混ざります。

パスを見れば、どの階層で止まったか分かる

テストではエラーメッセージの全文より、問題の場所を先に確かめます。文言は日本語化で変わり得ますが、items[1].sku のようなパスなら、2件目の品番と特定できます。

import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;

class OrderRequestTest {
    private final Validator validator = Validation.buildDefaultValidatorFactory()
            .getValidator();

    @Test
    void 二件目の明細を検証する() {
        var order = new OrderRequest("田中", List.of(
                new OrderItemRequest("A-001", 2),
                new OrderItemRequest("", 0)));
        Set<String> paths = validator.validate(order).stream()
                .map(v -> v.getPropertyPath().toString())
                .collect(Collectors.toSet());

        assertTrue(paths.contains("items[1].sku"));
        assertTrue(paths.contains("items[1].quantity"));
    }
}

ここでのインデックスは0始まりです。List内の null も確認するときは List.of(null) を使わないでください。List.of 自体が null を拒否するため、検証処理へ到達しません。Arrays.asList((OrderItemRequest) null) のようなListを作って別に試します。

直しても400にならない場合の確認順

  1. spring-boot-starter-validation が依存関係にあるか。
  2. コントローラーのルートDTO引数に @Valid があるか。
  3. 子が単体項目ならそのフィールド、Listなら要素型に @Valid があるか。
  4. 検証したい値に @NotBlank、@Min など適切な制約があるか。
  5. 保存処理が別の入口から呼ばれ、その入口で検証を省いていないか。

最後の点が実務では大切です。APIからは不正な値を拒否できても、バッチや別サービスが同じ保存処理へ渡せるなら業務上の不変条件は別途守る必要があります。まずこのAPIの入力を確実に止め、それから保存境界を確認します。APIで項目別エラーをJSONとして返す形は、別記事「Spring BootでバリデーションエラーをJSONで返す方法」で扱います。

参考資料

-実装・エラー解決
-,