Spring Bootのバリデーションとは、Jakarta Validation(旧Bean Validation)の制約アノテーションをフィールドや引数に付け、入力値を自動でチェックする仕組みです。spring-boot-starter-validation を追加し、@NotBlank や @Size などを付けた引数に @Valid か @Validated を付けると、Controllerに届いた値が検証されます。

検索上位の記事は、よく使う4〜5種のアノテーションの紹介や、javax.validation 前提の書き方が中心です。この記事では、Jakarta Validation 3.1の標準アノテーション22種すべてを「対象型・nullの扱い・例」で一覧化し、@Validと@Validatedの違いを比較表で整理します。さらに、エラーをJSONで返す @RestControllerAdvice(MethodArgumentNotValidException と HandlerMethodValidationException の両方)、日本語メッセージ、相関チェックのカスタムアノテーション、javaxからjakartaへの移行までを扱います。

対象:Spring Boot 4.1系(3.5系も同じ考え方)、Jakarta Validation 3.1、Hibernate Validator 9.0、Java 21。検証環境ではMaven Centralに接続できないためSpring本体は動かしておらず、Spring関連のコードは公式リファレンスとJavadocで確認して書いています(JSONは仕様に基づく出力例です)。外部ライブラリなしで動く部分(正規表現、文字数、メッセージファイル、カスタムバリデーターのロジック)はJava 21で実行した結果を載せています。

バリデーションアノテーションの早見表

Jakarta Validation 3.1の標準アノテーションは22種です(3.0から追加はなく、3.1ではrecordへの対応が明確になりました)。パッケージはすべて jakarta.validation.constraints です。

アノテーション チェック内容 主な対象型 nullのとき 例
@NotNull nullでない すべて エラー @NotNull Integer quantity
@NotEmpty nullでも空でもない 文字列・Collection・Map・配列 エラー @NotEmpty List<String> tags
@NotBlank nullでも空白だけでもない 文字列 エラー @NotBlank String name
@Null nullである すべて OK 作成時はidを送らせない
@Size 文字数・要素数の範囲 文字列・Collection・Map・配列 OK @Size(min = 2, max = 50)
@Min / @Max 整数の下限・上限 int・long・BigDecimal・BigInteger など OK @Min(1) @Max(99)
@DecimalMin / @DecimalMax 小数を含む下限・上限 数値型・数値の文字列 OK @DecimalMin(value = "0.0", inclusive = false)
@Positive / @PositiveOrZero 正の数/0以上 数値型 OK @PositiveOrZero int stock
@Negative / @NegativeOrZero 負の数/0以下 数値型 OK @Negative int adjustment
@Digits 整数部・小数部の桁数 数値型・数値の文字列 OK @Digits(integer = 6, fraction = 2)
@Past / @PastOrPresent 過去/過去か現在 LocalDate・LocalDateTime・Instant など OK @Past LocalDate birthDate
@Future / @FutureOrPresent 未来/未来か現在 日付・日時型 OK @Future LocalDate dueDate
@Pattern 正規表現に全体一致 文字列 OK @Pattern(regexp = "[0-9]{3}-[0-9]{4}")
@Email メールアドレスの形式 文字列 OK @Email String email
@AssertTrue / @AssertFalse trueである/falseである boolean・Boolean OK @AssertTrue boolean agreed

nullをエラーにするのは @NotNull・@NotEmpty・@NotBlank の3つだけです。それ以外はnullを「OK」として通すため、必須かつ形式チェックをしたい項目は @NotBlank と @Pattern のように組み合わせます。

やりたいこと 書き方
入力チェックを有効にする spring-boot-starter-validation を追加
リクエストボディ(JSON)を検証 @Valid @RequestBody CreateRequest req
パス変数・クエリパラメータを検証 @PathVariable @Positive long id(引数に直接付ける)
作成時と更新時でルールを変える @Validated(OnUpdate.class) とグループ
子オブジェクトやリストの中身も検証 フィールドに @Valid、List<@NotBlank String>
Serviceのメソッド引数を検証 クラスに @Validated を付ける
複数項目の関係(開始日≦終了日)を検証 クラスに付けるカスタムアノテーション
エラーをJSONで返す @RestControllerAdvice で2種類の例外を処理

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Bootバリデーション一覧|@Validと@Validated」

依存関係を追加する

Spring Boot 2.3以降、Webのstarterにはバリデーションが含まれていません。spring-boot-starter-validation を明示的に追加します(実装はHibernate Validator)。

<!-- pom.xml(Spring Boot 4系) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
// build.gradle(Spring Boot 4系)
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
}

Spring Boot 3系のWebのstarterは spring-boot-starter-web です。

Spring Boot Jakarta Validation Hibernate Validator パッケージ
2.x Bean Validation 2.0 6.x javax.validation
3.x 3.0 8.0 jakarta.validation
4.x 3.1 9.0 jakarta.validation

文字列のチェック:@NotNull・@NotEmpty・@NotBlankの違い

文字列の必須チェックは、基本的に @NotBlank を使います。3つの違いは、空文字と空白だけの文字列をどう扱うかです。

入力値 @NotNull @NotEmpty @NotBlank
null エラー エラー エラー
""(空文字) OK エラー エラー
" "(空白だけ) OK OK エラー
"abc" OK OK OK

@NotEmpty は文字列だけでなく、リストやMapが空でないことのチェックにも使えます。@NotBlank は文字列専用です。

@Sizeは「Stringのlength()」で数える

@Size の文字数は String#length()、つまりUTF-16の単位で数えます。絵文字などは1文字が2以上と数えられます。Java 21で同じ数え方を確認しました。

各文字列の length() と codePointCount() を表示した実行結果(OpenJDK 21.0.10):

abc length()=3 codePointCount=3
あいう length()=3 codePointCount=3
😀 length()=2 codePointCount=1
👨‍👩‍👧 length()=8 codePointCount=5

ひらがな・漢字は1文字=1、絵文字は2以上になります。「見た目の文字数」で制限したい場合は、後述のカスタムアノテーションで数え方を決めます。DBの列の長さ(文字数かバイト数か)とも合わせて上限を決めると安心です。

@Patternは「文字列全体」が一致するかで判定する

@Pattern は部分一致ではなく、文字列全体が正規表現に一致するかを見ます(Matcher#matches() と同じ考え方)。そのため ^ と $ は付けなくても全体一致になります。

正規表現 [0-9]{3}-[0-9]{4} で matches() と find() を比べた実行結果(OpenJDK 21.0.10):

123-4567     matches()=true  find()=true
郵便123-4567   matches()=false find()=true
123-45678    matches()=false find()=true

@Emailは形式の大まかなチェック

@Email は「@の前後に適切な文字がある」程度のチェックで、ドメインにドットがない形(user@localhost など)も通ります。「ドット付きのドメインに限りたい」など、より厳しくしたいときは regexp 属性を足します。

@NotBlank
@Email(regexp = "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
String email

数値と日付のチェック

整数は @Min/@Max、小数は @DecimalMin/@DecimalMax、符号だけなら @Positive などを使います。

やりたいこと 書き方 ポイント
1〜99の整数 @Min(1) @Max(99) int quantity 値は long で指定
0より大きい小数 @DecimalMin(value = "0.0", inclusive = false) BigDecimal price 値は文字列。inclusive = false で「より大きい」
0以上 @PositiveOrZero int stock 0を含む
整数6桁・小数2桁まで @Digits(integer = 6, fraction = 2) BigDecimal amount 桁数の上限
今日より前 @Past LocalDate birthDate 今日は含まない
今日以降 @FutureOrPresent LocalDate startDate 今日を含む

@Min と @Max の標準の対象は整数系と BigDecimal・BigInteger です(仕様上、double と float は丸め誤差のため対象外。Hibernate Validatorは独自に対応しています)。小数の範囲は BigDecimal と @DecimalMin の組み合わせが確実です。

プリミティブ型とラッパー型

JSONで項目が送られてこなかったとき、int は0、Integer はnullになります。

型 項目なしのとき 必須チェックの書き方
int quantity 0 @Min(1) で0を弾く(@NotNull は意味がない)
Integer quantity null @NotNull @Min(1)

「未入力」と「0」を区別したい項目はラッパー型にして @NotNull を付けます。

@Validと@Validatedの違い

@Valid はJakarta標準、@Validated はSpring独自のアノテーションです。どちらもControllerの引数で検証を起動できますが、使える場所と機能が違います。

項目 @Valid @Validated
パッケージ jakarta.validation.Valid org.springframework.validation.annotation.Validated
Controllerの @RequestBody 引数 使える 使える
グループ指定 できない(Defaultグループのみ) できる(@Validated(OnCreate.class))
フィールドに付けてネスト検証 できる できない(フィールドには付けられない)
クラスに付ける 意味を持たない Spring Beanのメソッド検証を有効にする
主な使いどころ ネスト・リストの中身・通常の入力チェック グループ切り替え・Serviceのメソッド検証

迷ったら、Controllerの引数とネストには @Valid、グループを使うときとServiceクラスには @Validated と覚えておくと整理しやすくなります。

ネスト:子オブジェクトの中も検証する

フィールドに @Valid を付けないと、子オブジェクトの中の制約は検証されません。リストの要素には、型引数に直接アノテーションを付けられます。

import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;

public record OrderRequest(
        @NotBlank @Size(max = 100) String customerName,

        @Valid @NotNull
        Address address,                       // Address の中の制約も検証

        @NotEmpty
        List<@Valid OrderLine> lines,          // 各明細の中も検証

        @Size(max = 5)
        List<@NotBlank @Size(max = 20) String> tags   // 各タグを検証
) {
    public record Address(
            @NotBlank @Pattern(regexp = "[0-9]{3}-[0-9]{4}") String postalCode,
            @NotBlank String city) {}

    public record OrderLine(
            @NotBlank String itemCode,
            @Min(1) @Max(99) int quantity) {}
}

エラーの項目名は address.postalCode、lines[0].quantity、tags[1] のように、どの階層のどの要素かがわかる形になります。

グループ:作成時と更新時でルールを変える

グループを使うと、同じクラスでも場面ごとに検証するアノテーションを切り替えられます。グループはただのインターフェースです。

import jakarta.validation.groups.Default;

public interface OnCreate extends Default {}
public interface OnUpdate extends Default {}
import jakarta.validation.constraints.*;

public record MemberForm(
        @Null(groups = OnCreate.class)      // 作成時はidを送らせない
        @NotNull(groups = OnUpdate.class)   // 更新時はidが必須
        Long id,

        @NotBlank @Size(max = 50)           // groups 指定なし = Default グループ
        String name
) {}
@PostMapping
public MemberResponse create(@Validated(OnCreate.class) @RequestBody MemberForm form) { ... }

@PutMapping("/{id}")
public MemberResponse update(@Validated(OnUpdate.class) @RequestBody MemberForm form) { ... }
グループ定義 @Validated(OnCreate.class) で検証される制約
interface OnCreate {} groups = OnCreate.class の制約だけ(name の @NotBlank は検証されない)
interface OnCreate extends Default {} OnCreateの制約 + groups指定なしの制約

groupsを指定しない制約は Default グループに属します。グループのインターフェースで Default を継承しておくと、共通のルールを書き直さずに済みます。

メソッド検証:Serviceの引数をチェックする

Spring Beanのクラスに @Validated を付けると、メソッドの引数や戻り値に付けた制約が検証されます。Spring Bootは、バリデーションの実装がクラスパスにあれば、この仕組み(MethodValidationPostProcessor)を自動で有効にします。

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;

@Service
@Validated
public class StockService {

    public int reserve(@NotBlank String itemCode, @Positive int quantity) {
        // ここに来た時点で itemCode と quantity はチェック済み
        return quantity;
    }
}

違反があると jakarta.validation.ConstraintViolationException が発生します。何もしないと500エラーになるので、入力の問題として扱うなら後述のハンドラーで400に変換します。

Controllerでの使い方:3つのパターン

Spring Framework 6.1(Spring Boot 3.2)以降、Controllerは引数に直接付けた制約を自動で検証します。パス変数やクエリパラメータのチェックのために、Controllerクラスへ @Validated を付ける必要はありません。

import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/members")
public class MemberController {

    // パターン1:リクエストボディ(JSON)
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public MemberResponse create(@Valid @RequestBody MemberCreateRequest request) {
        return new MemberResponse(1L, request.name());
    }

    // パターン2:パス変数・クエリパラメータに直接付ける
    @GetMapping("/{id}")
    public MemberResponse get(@PathVariable @Positive long id) {
        return new MemberResponse(id, "sample");
    }

    @GetMapping
    public List<MemberResponse> search(
            @RequestParam @Size(max = 50) String keyword,
            @RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit) {
        return List.of();
    }

    // パターン3:リストを丸ごと受け取る
    @PostMapping("/bulk")
    public int bulk(@RequestBody @Size(max = 100) List<@Valid MemberCreateRequest> requests) {
        return requests.size();
    }
}
public record MemberCreateRequest(
        @NotBlank @Size(max = 50) String name,
        @NotBlank @Email String email) {}

public record MemberResponse(long id, String name) {}

どの書き方をしたかで、発生する例外が変わります。公式リファレンスでは、次のように説明されています。

引数の書き方 発生する例外 ステータス
@Valid @RequestBody Form form(1つのオブジェクト) MethodArgumentNotValidException 400
@PathVariable @Positive long id など、引数に制約を直接付ける HandlerMethodValidationException 400
@RequestBody List<@Valid Form> などのリスト・Map HandlerMethodValidationException 400
@Valid の引数に @NotNull などの制約も付ける HandlerMethodValidationException 400
戻り値に制約を付けて違反した HandlerMethodValidationException 500

メソッドのどこかに「引数に直接付けた制約」があると、そのメソッドは丸ごとメソッド検証の対象になり、HandlerMethodValidationException にまとめられます。そのため、エラーハンドラーは2つの例外の両方を用意するのが基本です。

Controllerクラスに @Validated が付いていると、Spring MVC組み込みの検証ではなく、Serviceと同じAOPの仕組みが動き、ConstraintViolationException になります。公式リファレンスは、組み込みの検証を使うためにクラスの @Validated を外すよう案内しています。

エラーをJSONで返す:@RestControllerAdvice

3種類の例外を1か所で受け止め、同じ形のJSONに揃えます。ここでは {status, message, errors: [{field, message}]} という独自の形にしています。RFC 9457形式(ProblemDetail)で返す方法は、別記事「Spring BootでREST APIを作る」で解説しています。

import jakarta.validation.ConstraintViolationException;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.validation.method.ParameterValidationResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;

@RestControllerAdvice
public class ValidationErrorHandler {

    public record ErrorItem(String field, String message) {}
    public record ErrorBody(int status, String message, List<ErrorItem> errors) {}

    // 1. @Valid @RequestBody で1つのオブジェクトを検証したとき
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorBody> handleBody(MethodArgumentNotValidException ex) {
        List<ErrorItem> items = new ArrayList<>();
        for (FieldError fe : ex.getBindingResult().getFieldErrors()) {
            items.add(new ErrorItem(fe.getField(), fe.getDefaultMessage()));
        }
        // クラスに付けた制約(相関チェックなど)のエラー
        ex.getBindingResult().getGlobalErrors().forEach(ge ->
                items.add(new ErrorItem(ge.getObjectName(), ge.getDefaultMessage())));
        return ResponseEntity.badRequest()
                .body(new ErrorBody(400, "入力内容に誤りがあります", items));
    }

    // 2. 引数に直接付けた制約・リストの検証(Spring Framework 6.1 以降)
    @ExceptionHandler(HandlerMethodValidationException.class)
    public ResponseEntity<ErrorBody> handleMethod(HandlerMethodValidationException ex) {
        List<ErrorItem> items = new ArrayList<>();
        for (ParameterValidationResult result : ex.getParameterValidationResults()) {
            String param = result.getMethodParameter().getParameterName();
            for (MessageSourceResolvable error : result.getResolvableErrors()) {
                String field = (error instanceof FieldError fe) ? param + "." + fe.getField() : param;
                items.add(new ErrorItem(field, error.getDefaultMessage()));
            }
        }
        int status = ex.getStatusCode().value();   // 引数なら400、戻り値なら500
        return ResponseEntity.status(status)
                .body(new ErrorBody(status, "入力内容に誤りがあります", items));
    }

    // 3. @Validated を付けた Service などのメソッド検証
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorBody> handleViolation(ConstraintViolationException ex) {
        List<ErrorItem> items = ex.getConstraintViolations().stream()
                .map(v -> new ErrorItem(v.getPropertyPath().toString(), v.getMessage()))
                .toList();
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(new ErrorBody(400, "入力内容に誤りがあります", items));
    }
}
例外 エラーの取り出し方 項目名の取り方
MethodArgumentNotValidException getBindingResult().getFieldErrors() FieldError#getField()
HandlerMethodValidationException getParameterValidationResults() → getResolvableErrors() getMethodParameter().getParameterName()
ConstraintViolationException getConstraintViolations() getPropertyPath()(例:reserve.quantity)

レスポンスの例

次は、上のハンドラーの仕様に基づく出力例です(この記事の環境ではSpringを起動していないため、実行結果ではありません)。

POST /api/members に {"name": "", "email": "abc"} を送った場合:

{
  "status": 400,
  "message": "入力内容に誤りがあります",
  "errors": [
    { "field": "name", "message": "入力してください" },
    { "field": "email", "message": "メールアドレスの形式で入力してください" }
  ]
}

メッセージの文言は、次の章の ValidationMessages.properties の設定を前提にしています。エラーの並び順は保証されないので、テストで順番に依存しないようにします。

メッセージを日本語にする

メッセージの決め方は3通りあり、優先度は「アノテーションの message 属性 → 自分で置いたメッセージファイル → Hibernate Validator同梱の既定メッセージ」の順です。

方法 書き方 向いている場面
アノテーションに直接書く @NotBlank(message = "名前を入力してください") 項目ごとに文言を変えたい
ValidationMessages.properties 既定メッセージのキーを上書き アプリ全体で文言を統一したい
messages.properties(SpringのMessageSource) message = "{member.name.required}" から参照 画面の文言と一緒に管理したい

ValidationMessages.propertiesで全体の文言を揃える

src/main/resources/ValidationMessages.properties を置くと、標準アノテーションの既定メッセージを上書きできます。キーは jakarta.validation.constraints.{アノテーション名}.message です。

# src/main/resources/ValidationMessages.properties
jakarta.validation.constraints.NotBlank.message=入力してください
jakarta.validation.constraints.NotNull.message=入力してください
jakarta.validation.constraints.Size.message={min}〜{max}文字で入力してください
jakarta.validation.constraints.Max.message={value}以下で入力してください
jakarta.validation.constraints.Min.message={value}以上で入力してください
jakarta.validation.constraints.Email.message=メールアドレスの形式で入力してください
jakarta.validation.constraints.Pattern.message=形式が正しくありません
order.item.size=商品名は{max}文字以内で入力してください

{min} や {max}、{value} には、アノテーションの属性値が入ります。独自のキー(order.item.size)は @Size(max = 100, message = "{order.item.size}") のように参照します。

Java 9以降、.properties のリソースバンドルはUTF-8で読み込まれるため、日本語をそのまま書けます(\u 形式に変換する必要はありません)。Java 21で、UTF-8で保存したファイルが読めることを確認しました。

ResourceBundle.getBundle("ValidationMessages", Locale.JAPAN) で読み込んだ実行結果(OpenJDK 21.0.10):

入力してください
{min}〜{max}文字で入力してください
商品名は{max}文字以内で入力してください

ここで {min} などがそのまま残っているのは、ResourceBundleを直接読んだためです。Hibernate Validatorを通すと、この部分に属性値が埋め込まれます(埋め込み後の結果はこの環境では未確認です。仕様は公式ドキュメントで確認してください)。

Spring Bootでは、messages.properties(Springの MessageSource)のキーも制約のメッセージから参照できます。公式リファレンスによると、メッセージ中の {パラメータ} はまずアプリの MessageSource で解決され、そのあとBean Validationの標準の仕組みで埋め込みが行われます。画面の文言と同じファイルで管理したい場合に便利です。

カスタムアノテーションを作る

既存のアノテーションを組み合わせる(合成アノテーション)

よく使う組み合わせは、1つのアノテーションにまとめられます。次は「英大文字3桁-数字4桁」の備品コードを表す @StockCode です。

package com.example.validation;

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import jakarta.validation.ReportAsSingleViolation;
import jakarta.validation.constraints.Pattern;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

@Pattern(regexp = "[A-Z]{3}-[0-9]{4}")
@ReportAsSingleViolation          // 違反時は @StockCode のメッセージ1件だけにする
@Constraint(validatedBy = {})     // 独自のロジックなし(中のアノテーションで検証)
@Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE, TYPE_USE})
@Retention(RUNTIME)
@Documented
public @interface StockCode {
    String message() default "{stock.code.invalid}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

message・groups・payload の3つの属性は、制約アノテーションに必須です。どれかが欠けると、検証の実行時にエラーになります。

相関チェック:開始日と終了日の関係を見る

複数の項目の関係を検証するときは、クラス(record)に付けるアノテーションを作ります。ここでは、開始日と終了日を持つクラスに共通のインターフェースを用意し、どのリクエストでも使い回せるようにしています。

package com.example.validation;

import java.time.LocalDate;

public interface HasPeriod {
    LocalDate startDate();
    LocalDate endDate();
}
package com.example.validation;

import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

@Constraint(validatedBy = ValidPeriodValidator.class)
@Target(TYPE)
@Retention(RUNTIME)
@Documented
public @interface ValidPeriod {
    String message() default "終了日は開始日以降の日付を指定してください";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
package com.example.validation;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class ValidPeriodValidator implements ConstraintValidator<ValidPeriod, HasPeriod> {

    @Override
    public boolean isValid(HasPeriod value, ConstraintValidatorContext context) {
        if (value == null || value.startDate() == null || value.endDate() == null) {
            return true; // null のチェックは @NotNull に任せる
        }
        if (!value.endDate().isBefore(value.startDate())) {
            return true;
        }
        // エラーを「endDate」項目のエラーとして登録し直す
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
               .addPropertyNode("endDate")
               .addConstraintViolation();
        return false;
    }
}
package com.example.validation;

import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;

@ValidPeriod
public record ReservationRequest(
        @StockCode String equipmentCode,
        @NotNull LocalDate startDate,
        @NotNull LocalDate endDate
) implements HasPeriod {}

ポイントは3つです。

ポイント 理由
nullのときは true を返す 必須チェックは @NotNull の役割。二重にエラーが出るのを防ぐ
addPropertyNode("endDate") クラス全体のエラーではなく endDate 項目のエラーになり、画面やJSONで項目の横に表示できる
インターフェースで受ける recordのアクセサーがそのままインターフェースの実装になり、別のリクエストでも使い回せる

検証内容の確認:この環境ではHibernate Validatorを入手できないため、jakarta.validation の必要なインターフェースだけを持つスタブを作り、上の4ファイルをJava 21でコンパイルしたうえで、isValid を直接呼び出して動きを確かめました(記録用のダミーのコンテキストを渡しています)。

ok   -> true
same -> true
ng   -> false [disableDefault, template=終了日は開始日以降の日付を指定してください, node=endDate, added]
@StockCode の @Pattern: [A-Z]{3}-[0-9]{4}

同じ日(same)は通り、終了日が前の日(ng)のときだけ endDate 項目のエラーとして登録されます。Hibernate Validator上でのエラーの出方は公式ドキュメントの仕様に沿って書いています。

もっと手軽な方法:@AssertTrueのメソッド

1つのクラスでしか使わない相関チェックなら、booleanを返す is〜 メソッドに @AssertTrue を付ける方法もあります。

public record PasswordChangeRequest(
        @NotBlank @Size(min = 12) String newPassword,
        @NotBlank String confirmPassword) {

    @AssertTrue(message = "確認用パスワードが一致しません")
    public boolean isPasswordConfirmed() {
        return newPassword == null || newPassword.equals(confirmPassword);
    }
}

エラーの項目名は passwordConfirmed になります。使い回すならカスタムアノテーション、その場限りなら @AssertTrue と使い分けます。

javaxからjakartaへ移行するときの注意

Spring Boot 3以降は jakarta.validation パッケージを使います。javax.validation のアノテーションが残っていると、Hibernate Validator 8以降はそれを制約として認識しないため、エラーにならずに検証だけが行われない状態になります。移行後は、エラーになるはずの入力を送るテストで確かめておくと安心です。

確認すること 内容
import文 javax.validation.* → jakarta.validation.*
メッセージのキー javax.validation.constraints.NotBlank.message → jakarta.validation.constraints.NotBlank.message
依存関係 javax.validation:validation-api が残っていないか
置き換えてはいけないもの javax.sql・javax.crypto など、JDK標準のパッケージ

置き換えは、javax.validation だけを対象にするのが安全です。次のコマンドで、サンプルのファイルを変換しました。

grep -rn --include='*.java' 'import javax\.' src
grep -rl --include='*.java' 'javax\.validation' src | xargs sed -i 's/javax\.validation/jakarta.validation/g'

変換後のファイル(GNU sed 4.9で実行):

package com.example;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import javax.crypto.Cipher;
import javax.sql.DataSource;

javax.crypto と javax.sql はJDKの一部なので、そのまま残ります。ValidationMessages.properties のキーも忘れずに置き換えます。大きなコードベースでは、OpenRewriteのJakarta移行レシピのような自動変換ツールも選択肢になります。

検証が動かないときのチェックリスト

症状 確認すること 直し方
何を送ってもエラーにならない spring-boot-starter-validation があるか 依存関係を追加
何を送ってもエラーにならない importが javax.validation になっていないか jakarta.validation に置き換え
子オブジェクトの中が検証されない フィールドに @Valid があるか @Valid Address address
リストの要素が検証されない 型引数に付けているか List<@Valid Item>
@NotNull が効かない 型が int などのプリミティブでないか Integer に変える
想定外の ConstraintViolationException で500 Controllerクラスに @Validated がないか クラスの @Validated を外す、またはハンドラーを追加
グループ指定でほかの制約が検証されない グループが Default を継承しているか interface OnCreate extends Default {}
Serviceのメソッド検証が効かない クラスに @Validated があるか、同じクラス内から呼んでいないか 別のBeanから呼ぶ(AOPの仕組みのため)

よくある質問

Q. @Validと@Validated、Controllerではどちらを使えばよいですか? A. グループを使わないなら @Valid で十分です。グループで作成時・更新時のルールを切り替えるときだけ @Validated(OnCreate.class) を使います。ネストした子オブジェクトには、フィールドに @Valid を付けます(@Validated はフィールドに付けられません)。

Q. @PathVariableや@RequestParamのチェックに、Controllerクラスの@Validatedは必要ですか? A. Spring Framework 6.1(Spring Boot 3.2)以降は不要です。引数に @Positive などを直接付けるだけで、組み込みのメソッド検証が動き、HandlerMethodValidationException になります。クラスに @Validated を付けると別の仕組み(ConstraintViolationException)になるため、公式リファレンスでは外すよう案内されています。

Q. MethodArgumentNotValidExceptionだけ処理すれば足りますか? A. HandlerMethodValidationException も処理しておくのが安心です。引数に直接付けた制約や、List を受け取る引数の検証では、こちらの例外になります。両方を同じ形のJSONに揃えると、APIを使う側が扱いやすくなります。

まとめ

  • spring-boot-starter-validation を追加し、制約は jakarta.validation.constraints のアノテーションで付ける
  • nullをエラーにするのは @NotNull・@NotEmpty・@NotBlank だけ。形式チェックと必須チェックは組み合わせる
  • @Valid はネストとリストの中身、@Validated はグループとServiceのメソッド検証に使う
  • Controllerは引数に直接制約を付けられる(Spring Framework 6.1以降)。例外は MethodArgumentNotValidException と HandlerMethodValidationException の2つを処理する
  • 日本語メッセージは ValidationMessages.properties(UTF-8のまま書ける)で一括設定する
  • 相関チェックはクラスに付けるカスタムアノテーションで、addPropertyNode で項目にエラーをひも付ける
  • javaxからの移行は javax.validation だけを置き換え、エラーになる入力を送って検証が動くことを確かめる

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Bootバリデーション一覧|@Validと@Validated」

株式会社RJCでは、Spring Bootの入力チェックの設計を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?