Spring BootでのBean Validationを使ったバリデーション(@Valid, @NotBlank, …)

1. 概要

REST APIを構築する際、業務ロジックを処理する前に必ず入力データを検証する必要があります。メールのフォーマットが正しいか、名前が空でないか、年齢が正の数か…など。Bean Validation(JSR 380の実装はHibernate Validator)を使えば、これらの検証をアノテーションで実現できます。手作業でif-elseを山のように書く必要はありません。

このガイドでは、@Validとよく使われるアノテーション、ネストされたオブジェクトのバリデーション、カスタムバリデータの作成、クライアントに優しいエラーレスポンスの返し方について説明します。読み終わった後、Spring BootプロジェクトのあらゆるDTOにバリデーションを自信を持って適用できるようになります。

2. 依存関係の追加

Spring BootはBean Validationをデフォルトで含んでいないため、spring-boot-starter-validationを追加する必要があります:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

この依存関係はHibernate Validator(Bean Validation APIで最も一般的な実装)を引き込みます。spring-boot-starter-webを使っている場合、バリデーションがすでに含まれていると思っている人も多いですが、Spring Boot 2.3以降はstarter-validationが別になりました。これがないと、@Validは機能せず、エラーも報告されず、不正なデータが素通りしてしまいます。

3. @Validを使ったリクエストボディのバリデーション

3.1. Constraint アノテーション付きDTOの宣言

public record CreateOrderRequest(
        @NotBlank(message = "顧客名は空にできません")
        String customerName,

        @Email(message = "メールアドレスが無効です")
        @NotBlank
        String email,

        @Positive(message = "数量は0より大きい必要があります")
        Integer quantity,

        @DecimalMin(value = "0.0", inclusive = false, message = "価格は0より大きい必要があります")
        BigDecimal unitPrice
) {}

各フィールドに1つ以上のconstraint アノテーションを設定します。@NotBlankはnull/空文字列/空白のみを禁止し、@Emailはメールフォーマットをチェック、@Positiveは正の数を要求、@DecimalMinは最小値を制限します。messageパラメータは制約に違反した時のエラーメッセージです。

3.2. Controllerで@Validを適用

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @PostMapping
    public ResponseEntity<String> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        // この時点で、requestは必ず有効です
        return ResponseEntity.ok("Order created for " + request.customerName());
    }
}

@Validを@RequestBodyの前に置くと、Spring MVCはJSONから逆シリアル化されたオブジェクトに対してバリデーションを実行します。いずれかのフィールドが制約に違反すると、Spring Frameworkはメソッドボディが実行される前にMethodArgumentNotValidExceptionをスローします。つまり、ビジネスロジック内でnullや空をチェックする必要がなくなります。

customerNameがないリクエストを送った場合:

POST /api/orders
{
  "email": "a@b.com",
  "quantity": 2,
  "unitPrice": 10.5
}

デフォルト(exceptionハンドラなし)では、Spring FrameworkはHTTP 400を長くて読みにくいボディで返します。セクション5でこれをより簡潔に処理する方法を説明します。

4. よく使われるアノテーション

Annotation 意味
@NotNull 値がnullであってはいけない
@NotBlank 文字列がnull、空、または空白のみではない
@NotEmpty Collection/String/Arrayがnullまたは空ではない
@Size(min, max) 文字列の長さまたはCollectionサイズ
@Min / @Max 整数の範囲制限
@Positive / @Negative 正の数/負の数
@Email メールフォーマット
@Pattern(regexp) 正規表現マッチ
@Past / @Future 過去/未来の日付

複数のconstraintを1つのフィールドに組み合わせる例:

public class Customer {

    @Size(min = 3, max = 50, message = "名前は3~50文字である必要があります")
    private String name;

    @Pattern(regexp = "^0\\d{9}$", message = "電話番号は10桁で、0で始まる必要があります")
    private String phone;

    @Past(message = "生年月日は過去である必要があります")
    private LocalDate birthDate;
}

同じフィールドに複数のアノテーションを設定できます。例えば、@NotBlankと@Sizeを組み合わせることで、空を禁止しながら長さを制限できます。バリデータはすべての制約を実行し、エラーを集約します。最初のエラーで停止しません。

5. @ExceptionHandlerを使ったバリデーションエラー処理

デフォルトのレスポンス形式の代わりに、@RestControllerAdviceを使ってフォーマットし直します:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationErrors(
            MethodArgumentNotValidException ex) {

        Map<String, String> errors = new HashMap<>();
        for (FieldError error : ex.getBindingResult().getFieldErrors()) {
            errors.put(error.getField(), error.getDefaultMessage());
        }
        return ResponseEntity.badRequest().body(errors);
    }
}

MethodArgumentNotValidExceptionはBindingResultを含み、その中にFieldErrorのリストがあります。各エラーにはフィールド名とメッセージが含まれます。上記コードはすべてのエラーをMap<field, message>にまとめて、HTTP 400を返します。

上記のcustomerNameがないリクエストを呼び出した結果:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "customerName": "顧客名は空にできません"
}

レスポンスが簡潔になり、クライアントはフォーム上の各フィールドにエラーを簡単にマップできます。

6. ネストされたオブジェクトとコレクションのバリデーション

6.1. @Validを使ったネストされたオブジェクト

public record AddressRequest(
        @NotBlank String city,
        @NotBlank String street
) {}

public record CustomerRequest(
        @NotBlank String name,

        @Valid
        @NotNull
        AddressRequest address
) {}

CustomerRequest.addressに@NotNullだけがあって@Validがない場合、AddressRequest内の制約(例えばcityの@NotBlank)はチェックされません。これはBean Validation初心者がよく犯すエラーです。ここで@Validは、バリデータが子オブジェクトに「深く潜る」ことを指示します。

6.2. List内の各要素のバリデーション

public record BulkOrderRequest(
        @NotEmpty(message = "アイテムリストは空にできません")
        @Valid
        List<@Valid OrderItem> items
) {}

public record OrderItem(
        @NotBlank String productCode,
        @Positive Integer quantity
) {}

@ValidをList<...>の直前に(またはtype argumentに)置くと、バリデータはリスト内の各要素をチェックします。リストがnullであるかどうかだけでなく。リスト内の2番目のアイテムがquantity = -1の場合、返されるエラーはフィールドをitems[1].quantityの形式で示します。

7. カスタムバリデータの作成

既存の制約では不十分な場合、独自のアノテーションを作成できます。例えば、注文コードがORD-で始まる必要があるかをチェックします:

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderCodeValidator.class)
public @interface ValidOrderCode {
    String message() default "注文コードはORD-xxxxxの形式である必要があります";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
public class OrderCodeValidator implements ConstraintValidator<ValidOrderCode, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return true; // @NotNullに任せる、二重エラーを避ける
        }
        return value.matches("^ORD-\\d{5}$");
    }
}

@Constraint(validatedBy = ...)はアノテーションをロジックを処理するクラスに関連付けます。ConstraintValidator<A, T>はAがアノテーション、Tがフィールドのデータ型です。isValidメソッドは有効な場合はtrueを返します。注意:value == nullの場合、isValidはtrueを返すべきです。なぜなら、not-nullの強制は@NotNullに任せるべきで、1つのフィールドが2つの異なるメッセージで2回エラーになるのを避けるためです。

DTOで使用:

public record OrderLookupRequest(
        @NotBlank
        @ValidOrderCode
        String orderCode
) {}

8. JUnit 5 + AssertJでのバリデーションテスト

Spring MVCのcontextを起動せずにValidatorを直接テストでき、統合テストより高速です:

class CreateOrderRequestValidationTest {

    private static Validator validator;

    @BeforeAll
    static void setUp() {
        try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) {
            validator = factory.getValidator();
        }
    }

    @Test
    void shouldRejectBlankCustomerName() {
        CreateOrderRequest request = new CreateOrderRequest(
                "", "a@b.com", 2, BigDecimal.TEN);

        Set<ConstraintViolation<CreateOrderRequest>> violations = validator.validate(request);

        assertThat(violations)
                .extracting(v -> v.getPropertyPath().toString())
                .contains("customerName");
    }

    @Test
    void shouldPassWhenAllFieldsValid() {
        CreateOrderRequest request = new CreateOrderRequest(
                "Nguyen Van A", "a@b.com", 2, BigDecimal.TEN);

        Set<ConstraintViolation<CreateOrderRequest>> violations = validator.validate(request);

        assertThat(violations).isEmpty();
    }
}

Validation.buildDefaultValidatorFactory()はSpring contextなしで独立したValidatorを作成します。validator.validate(request)はSet<ConstraintViolation>を返します。空の場合は有効です。このテスト方法はDTOのユニットテストに適していて、controllerロジックから分離できます。

9. Path変数とクエリパラメータのバリデーション

@Validを@RequestBodyに使うのが最も一般的ですが、controllerクラスに@Validatedを追加することで、単一のパラメータもバリデーションできます:

@RestController
@RequestMapping("/api/orders")
@Validated
public class OrderQueryController {

    @GetMapping("/{id}")
    public ResponseEntity<String> getOrder(
            @PathVariable @Positive(message = "IDは0より大きい必要があります") Long id) {
        return ResponseEntity.ok("Order " + id);
    }
}

@Validated(Spring提供、Bean Validationの@Validとは異なる)はメソッドパラメータレベルでバリデーションを有効にします。id = -1の場合、Spring FrameworkはConstraintViolationExceptionをスローします(セクション5のMethodArgumentNotValidExceptionとは異なる)。このexceptionに対して@ExceptionHandlerが必要です:

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<String> handleConstraintViolation(ConstraintViolationException ex) {
    return ResponseEntity.badRequest().body(ex.getMessage());
}

注意: これら2つのexceptionは混同しやすいです。MethodArgumentNotValidExceptionは@Valid @RequestBody/@ModelAttribute用で、ConstraintViolationExceptionは@Validatedのpath変数/クエリパラメータ用です。どちらかのハンドラが欠けていると、エラーはデフォルトのレスポンス500に落ちてしまい、非常に見栄えが悪いです。

10. よくあるエラーと代替案

  • @Validを忘れること: constraint アノテーションは自動的には起動しません。@Validを正しい場所(controllerパラメータ、ネストされたオブジェクトのフィールド、genericsのtype argument)で宣言する必要があります。
  • starter-validation依存関係の欠落: constraint アノテーションが静かに無視され、例外が発生しません。テストを書かないと発見が非常に難しいです。
  • 複数フィールドにまたがる複雑なバリデーションロジック(例:「終了日は開始日より後である必要がある」): Bean Validationはclass-level constraintで対応できますが、データベースのデータに依存するロジック(例:「メールアドレスがまだ存在しない」)の場合、バリデータではなくservice層で処理すべきです。バリデータはrepositoryを呼び出すべきではなく、テストが難しくなり、単一責任の原則に違反します。
  • 多言語エラーメッセージ: プロジェクトでi18nが必要な場合、アノテーション内にベトナム語をハードコードするのではなく、message = "{order.code.invalid}"とファイルmessages.propertiesを使用します。

11. 結論

Bean Validationを使うことで、バリデーションルールをDTOのアノテーションで宣言でき、分散したif-elseコードは不要になります。@Valid/@Validatedと@RestControllerAdviceを組み合わせることで、API全体で一貫したバリデーション・エラー報告フローが実現できます。既存の制約では不十分な場合、カスタムバリデータで柔軟に拡張できながら、宣言的なスタイル、読みやすさ、テストのしやすさを保ちます。

spring-bootvalidationbean-validationjavarest-api