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全体で一貫したバリデーション・エラー報告フローが実現できます。既存の制約では不十分な場合、カスタムバリデータで柔軟に拡張できながら、宣言的なスタイル、読みやすさ、テストのしやすさを保ちます。