データ検証
データ検証はAPIの第一防衛線です。無効な入力はビジネスロジックに到達する前に拒否されるべきで, 早ければ早いほどよい。
1. 学ぶ内容
@Valid/@Validatedアノテーションと検証トリガーメカニズム- 一般的な制約アノテーション:
@NotNull/@Size/@Pattern/@Email/@Min/@Max - カスタムバリデータ
ConstraintValidator<A, T>の実装 - グループ検証
groupsでシナリオ別の検証ルールを区別 (Create vs. Update) - 検証エラーレスポンスの統一フォーマット
2. API開発者の実話
(1) ペインポイント:汚いデータがデータベースに保存される
AliceはOrderFlowデータベースに奇妙なデータを発見しました。注文数量が-5, メールアドレスが"abc", 商品価格が0です。Bobは, ユーザーがAPI経由でマイナスの在庫を持つ商品を送信し, レポート統計に異常が発生したと報告しました。Aliceはこれまでサービス層に大量のif-else検証コードを書いていましたが, 冗長で見落としがちでした。
(2) Bean Validationによる解決策
アノテーションで検証ルールを宣言し, Springが自動的に検証をトリガーします。
public record CreateOrderRequest(
@NotNull Long productId,
@Min(1) @Max(100) Integer quantity,
@Email String customerEmail
) {}
無効なリクエストはコントローラに到達する前に拒否されます。
(3) 成果
Aliceはすべての手動検証コードをBean Validationに置き換え, コントローラのコード量を40%削減し, 検証ルールの見落としをなくしました。データベースに汚いデータはもうありません。
3. Bean Validationアノテーション体系
(1) 検証の実行フロー
graph TD
A["クライアントリクエスト<br/>@RequestBody"] --> B{"@Valid<br/>トリガーされた?"}
B -->|はい| C["Hibernate Validator<br/>制約をチェック"]
C --> D{"すべて有効?"}
D -->|はい| E["コントローラメソッド<br/>実行"]
D -->|いいえ| F["MethodArgumentNotValidException<br/>400 Bad Request"]
B -->|いいえ| G["検証スキップ<br/>汚いデータの可能性"]
(3) 検証トリガー方法
| アノテーション | 用途 | 配置場所 |
|---|---|---|
@Valid |
カスケード検証のトリガー (ネストされたオブジェクトを含む) | メソッドパラメータ, フィールド |
@Validated |
グループ検証をサポート | クラス, メソッドパラメータ |
@Validated(Group.class) |
検証グループの指定 | メソッドパラメータ |
(1) ▶ サンプル:コントローラパラメータの検証
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
@PostMapping
public ResponseEntity<Order> createOrder(
@Valid @RequestBody CreateOrderRequest request) {
// 検証に失敗するとMethodArgumentNotValidExceptionがスローされ
// この行には到達しない
Order order = orderService.createOrder(request);
return ResponseEntity.status(HttpStatus.CREATED).body(order);
}
}
public record CreateOrderRequest(
@NotNull(message = "商品IDは必須です")
Long productId,
@Min(value = 1, message = "数量は1以上である必要があります")
@Max(value = 100, message = "数量は100を超えることはできません")
Integer quantity,
@Email(message = "メールアドレスの形式が不正です")
String customerEmail
) {}
出力:
// 実行成功
4. 一般的な制約アノテーション
(1) 制約アノテーションのクイックリファレンス
| アノテーション | 適用型 | 説明 | 例 |
|---|---|---|---|
@NotNull |
すべての型 | null不可 | @NotNull Long id |
@NotBlank |
String | 空/null/スペースのみ不可 | @NotBlank String name |
@NotEmpty |
String/Collection | 空/null不可 | @NotEmpty List<String> tags |
@Size |
String/Collection | 長さ/サイズの範囲 | @Size(min=2, max=100) |
@Min / @Max |
数値型 | 値の範囲 | @Min(0) @Max(99999) |
@Positive |
数値型 | 正の数 | @Positive BigDecimal price |
@Email |
String | メール形式 | @Email String email |
@Pattern |
String | 正規表現マッチ | @Pattern(regexp="^[A-Z]") |
@Past / @Future |
日付型 | 過去/未来 | @Past LocalDate birthDate |
(1) ▶ サンプル:Product DTO検証
public record CreateProductRequest(
@NotBlank(message = "商品名は必須です")
@Size(min = 2, max = 200, message = "名前は2〜200文字である必要があります")
String name,
@NotNull(message = "価格は必須です")
@Positive(message = "価格は正の数である必要があります")
@DecimalMin(value = "0.01", message = "価格は0.01以上である必要があります")
BigDecimal price,
@NotNull(message = "在庫数は必須です")
@Min(value = 0, message = "在庫数はマイナスにできません")
Integer stock,
@Email(message = "サプライヤーメールアドレスが不正です")
String supplierEmail,
@Pattern(regexp = "^[A-Z]{3}-\\d{4}$", message = "SKU形式:XXX-0000")
String sku
) {}
出力:
// 実行成功
| アノテーション比較 | null | "" | " " | "abc" |
|---|---|---|---|---|
@NotNull |
❌ | ✅ | ✅ | ✅ |
@NotBlank |
❌ | ❌ | ❌ | ✅ |
@NotEmpty |
❌ | ❌ | ✅ | ✅ |
@NotNullではなく@NotBlankを使用してください。空文字列も通常は無効だからです。
5. カスタムバリデータ
(1) 実装手順
- 制約アノテーションの定義
ConstraintValidator<A, T>の実装- DTOフィールドで使用
(1) ▶ サンプル:カスタム@ValidOrderQuantityバリデータ
// ステップ1:制約アノテーションの定義
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderQuantityValidator.class)
public @interface ValidOrderQuantity {
String message() default "注文数量が商品在庫制限を超えています";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// ステップ2:ConstraintValidatorの実装
public class OrderQuantityValidator
implements ConstraintValidator<ValidOrderQuantity, Integer> {
private static final int MAX_QUANTITY_PER_ITEM = 100;
@Override
public boolean isValid(Integer quantity, ConstraintValidatorContext context) {
if (quantity == null) {
return true; // nullチェックは@NotNullに任せる
}
return quantity >= 1 && quantity <= MAX_QUANTITY_PER_ITEM;
}
}
// ステップ3:DTOで使用
public record CreateOrderRequest(
@NotNull Long productId,
@ValidOrderQuantity Integer quantity
) {}
出力:
// 実行成功
6. グループ検証
(1) シナリオ別の検証ルールの分類
「作成」と「更新」のシナリオでは通常, 異なる検証ルールが必要です。
| シナリオ | 商品ID | 名前 | 価格 |
|---|---|---|---|
| 作成 | 自動生成, 不要 | 必須 | 必須 |
| 更新 | 必須 (変更対象を示すため) | オプション | オプション |
(1) ▶ サンプル:グループ検証
// グループインターフェースの定義
public interface Create {}
public interface Update {}
// グループ対応検証付きDTO
public record ProductRequest(
@Null(groups = Create.class, message = "作成時にIDはnullである必要があります")
@NotNull(groups = Update.class, message = "更新時にIDは必須です")
Long id,
@NotBlank(groups = Create.class, message = "作成時に名前は必須です")
@Size(min = 2, max = 200)
String name,
@NotNull(groups = Create.class, message = "作成時に価格は必須です")
@Positive BigDecimal price
) {}
出力:
// 実行成功
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
@PostMapping
public ResponseEntity<Product> create(
@Validated(Create.class) @RequestBody ProductRequest request) {
// Createグループの検証のみが適用される
// ...
return ResponseEntity.status(HttpStatus.CREATED).build();
}
@PutMapping("/{id}")
public ResponseEntity<Product> update(
@PathVariable Long id,
@Validated(Update.class) @RequestBody ProductRequest request) {
// Updateグループの検証のみが適用される
// ...
return ResponseEntity.ok().build();
}
}
(2) ▶ サンプル:ネストされた検証
public record CreateOrderRequest(
@NotNull Long productId,
@ValidOrderQuantity Integer quantity,
@Valid @NotNull ShippingAddress shippingAddress
) {}
public record ShippingAddress(
@NotBlank String street,
@NotBlank String city,
@NotBlank String zipCode,
@Pattern(regexp = "^[A-Z]{2}$") String country
) {}
出力:
// 実行成功
@Validを付ける必要があります。付けないとネストされたフィールドの検証が有効になりません。@Validatedはネストされたカスケード検証をサポートしていません。
7. 検証エラーレスポンスのフォーマット
(1) エラーレスポンス形式の標準化
@RestControllerAdvice
public class ValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", Instant.now());
body.put("status", HttpStatus.BAD_REQUEST.value());
List<Map<String, String>> errors = ex.getBindingResult()
.getFieldErrors().stream()
.map(fe -> Map.of(
"field", fe.getField(),
"message", fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "",
"rejectedValue", fe.getRejectedValue() != null ? fe.getRejectedValue().toString() : "null"
))
.toList();
body.put("errors", errors);
return ResponseEntity.badRequest().body(body);
}
}
{
"timestamp": "2024-01-15T10:00:00Z",
"status": 400,
"errors": [
{"field": "quantity", "message": "数量は1以上である必要があります", "rejectedValue": "0"},
{"field": "customerEmail", "message": "メールアドレスの形式が不正です", "rejectedValue": "abc"}
]
}
8. 総合サンプル:OrderFlowの完全な検証システム
// 検証グループ
package com.orderflow.validation;
public interface Create {}
public interface Update {}
// カスタムバリデータ:@ValidShippingAddress
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ShippingAddressValidator.class)
public @interface ValidShippingAddress {
String message() default "配送先住所が不正です";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class ShippingAddressValidator
implements ConstraintValidator<ValidShippingAddress, String> {
@Override
public boolean isValid(String address, ConstraintValidatorContext ctx) {
if (address == null || address.isBlank()) return false;
return address.length() >= 10 && address.length() <= 500;
}
}
// DTO群
public record CreateOrderRequest(
@NotNull(groups = Create.class) Long productId,
@Min(value = 1, message = "数量は1以上である必要があります")
@Max(value = 100, message = "数量は100を超えることはできません")
Integer quantity,
@Email String customerEmail,
@ValidShippingAddress String shippingAddress
) {}
public record CreateProductRequest(
@Null(groups = Create.class) @NotNull(groups = Update.class) Long id,
@NotBlank(groups = Create.class) @Size(min = 2, max = 200) String name,
@NotNull(groups = Create.class) @Positive BigDecimal price,
@Min(0) Integer stock,
@Pattern(regexp = "^[A-Z]{3}-\\d{4}$", message = "SKU:XXX-0000") String sku
) {}
// コントローラ
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
@PostMapping
public ResponseEntity<Void> create(
@Validated(Create.class) @RequestBody CreateOrderRequest req) {
// 検証通過, ビジネスロジックに進む
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
❓ よくある質問
public interface Create extends Default {}。resources/ValidationMessages.propertiesにメッセージキーを定義してください。例:order.quantity.invalid=注文数量は{min}から{max}の間である必要があります。多言語ファイルもサポートされています。📖 まとめ
@Valid/@Validatedが検証をトリガー。検証失敗でMethodArgumentNotValidExceptionがスローされる- 一般的なアノテーション:Stringには
@NotBlank, 数値には@Min/@Max/@Positive, メールには@Email - カスタムバリデータの3ステップ:アノテーション定義 → ConstraintValidator実装 → 使用
- グループ検証でCreateとUpdateシナリオを区別。
@Validated(Group.class)でグループを指定 - ネストされたオブジェクトには
@Validを付けてカスケード検証を有効にする必要がある @RestControllerAdviceでエラーレスポンスのフォーマットを標準化
📝 練習問題
-
基本問題 (難易度 ⭐):OrderFlowの
CreateOrderRequestとCreateProductRequestにBean Validationアノテーションを追加し, 無効な入力を検証して400エラーを返すようにしてください。 -
応用問題 (難易度 ⭐⭐):グループ検証を実装してください。
Create操作ではnameとpriceが必須,Update操作ではidが必須でnameとpriceはオプション。カスタム@ValidShippingAddressバリデータを実装してください。 -
チャレンジ問題 (難易度 ⭐⭐⭐):
@UniqueProductSkuバリデータを作成し,ProductRepositoryをインジェクションしてSKUの既存チェックを行い, データベースレベルの一意性検証を実装してください。バリデータとビジネス層の責任の境界について考察してください。



