Spring Bootでバリデーションを書くとき、まず登場するのが@Validです。ですが、実務で少し複雑な入力チェックをやり始めると「登録と更新で必須項目を変えたい」「ControllerだけでなくServiceのメソッド引数も自動で検証したい」といった要望が出てきます。
そんなときに頼りになるのが、Springの@Validatedアノテーションです。@Validの延長線上にありつつ、グループバリデーション や メソッド単位のバリデーション を自然に扱えるのが非常に便利です。
@Validatedとは何か?
@Validatedは、Springが提供するバリデーション用アノテーション(org.springframework.validation.annotation.Validated)です。役割としては「この対象にバリデーションをかける」という点で@Validと似ていますが、@Validatedは バリデーショングループ(groups)を指定できる のが特徴です。
また、Springでは@Validatedをクラスに付けることで、 そのBeanのメソッド引数・戻り値を自動検証 (メソッドバリデーション) できるようになります。
使うために必要な依存とバージョンの前提
本記事のコードは Spring Boot 3.x(Java 17 以上)を前提にしています。@Validated自体は Spring Framework に含まれていますが、@NotBlankや@Emailなどの制約アノテーションを使うにはspring-boot-starter-validationの追加が必要です。spring-boot-starter-webだけでは入らないので注意してください。
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
}
このStarterを入れると、Jakarta Bean Validation の実装である Hibernate Validator が一緒に入り、Spring Boot がLocalValidatorFactoryBeanとMethodValidationPostProcessorを自動構成してくれます。つまり、依存を1行足すだけで、ControllerのDTO検証も、クラスに付けた@Validatedによるメソッドバリデーションも動く状態になります。Starterが依存をまとめてくれる仕組みについては Spring Boot Starterって何? で解説しています。
制約アノテーションのパッケージは、Spring Boot 3.x ではjavax.validationではなくjakarta.validationです。2.x からアップグレードしてimportが解決できないときは、この置き換え漏れをまず疑ってみてください。移行手順は Spring Boot 2.x から 3.x への移行ガイド で解説しています。
@Validとの違いと使い分け
@Validと@Validatedは似ていますが、得意分野が少し違います。
@Valid(Jakarta標準)- DTOの検証を「トリガー」する用途でシンプル
- Controllerで
@RequestBodyを検証する、ネストしたオブジェクトを再帰的に検証する、などで扱いやすい
@Validated(Spring提供)- グループバリデーション を扱える(登録/更新でルールを変える、など)
- Service層のメソッド引数や戻り値 の検証に向いている
迷ったら次のイメージが分かりやすいです。
- 「ControllerでDTOを普通に検証したい」→ まずは
@Valid - 「検証ルールを用途別に切り替えたい」→
@Validated - 「Service層のメソッド境界でも検証したい」→ クラスに
@Validated
違いを表で整理すると次のようになります。
| 観点 | @Valid | @Validated |
|---|---|---|
| 提供元 | Jakarta Bean Validation(jakarta.validation.Valid) | Spring Framework(org.springframework.validation.annotation.Validated) |
| グループ指定 | できない | @Validated(OnCreate.class)のように指定できる |
| ネストしたオブジェクトの再帰検証 | フィールドに付けて再帰的に検証できる | フィールドには付けられない(再帰は@Validで指示する) |
| メソッドバリデーションの有効化 | できない | クラスに付けるとそのBeanの引数と戻り値を検証する |
| 付けられる場所 | メソッド引数、フィールド、コンストラクタ引数など | クラス、メソッド、メソッド引数 |
@RequestBodyの検証に失敗したときの例外 | MethodArgumentNotValidException | MethodArgumentNotValidException |
Controllerの引数に@Validと@Validatedを両方付けた場合、Springは引数のアノテーションを順に見て、最初に見つかった@Valid系アノテーションのヒントで1回だけ検証します。二重に検証されることはありませんが、どちらのグループ設定が使われるかが並び順に依存してしまうので、同じ引数にはどちらか一方だけを付けるようにしましょう。
@Valid単体での基本的な使い方や、ネストしたDTOを再帰的に検証する書き方については Spring Boot @Validアノテーションでバリデーションをシンプルに実装する方法 で解説しています。
グループバリデーションで登録と更新のルールを分ける
例えばユーザーの「新規作成」と「更新」で、必須項目を変えたいケースを考えてみます。
グループ用のインターフェースを用意する
グループはただのマーカーなので、空のインターフェースでOKです。
public interface OnCreate {}
public interface OnUpdate {}
DTOにgroupsを指定して制約を分ける
groups属性を使うと、どの制約をどの場面で有効にするかを切り替えられます。
public class UserRequest {
@NotBlank(message = "名前は必須です", groups = {OnCreate.class, OnUpdate.class})
@Size(min = 2, max = 20, message = "名前は2〜20文字で入力してください", groups = {OnCreate.class, OnUpdate.class})
private String name;
@NotBlank(message = "メールアドレスは新規登録時に必須です", groups = OnCreate.class)
@Email(message = "メールアドレスの形式が正しくありません", groups = {OnCreate.class, OnUpdate.class})
private String email;
// getter/setter
}
ここでのポイントは、@Validatedが「どのグループで検証するか」を決めるスイッチになっていることです。
Controllerで@Validated(グループ)を指定する
新規作成ではOnCreate、更新ではOnUpdateを指定します。
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping
public String create(@RequestBody @Validated(OnCreate.class) UserRequest request) {
return "created";
}
@PutMapping("/{id}")
public String update(@PathVariable Long id, @RequestBody @Validated(OnUpdate.class) UserRequest request) {
return "updated";
}
}
@Validではグループ指定ができないため、この切り替えは@Validatedの得意分野です。
Service層でメソッドバリデーションを有効にする
Controllerでの検証だけだと、別の経路(バッチ、イベント処理、別Controllerなど)からServiceが呼ばれたときにチェックが漏れることがあります。そこで便利なのが メソッドバリデーション です。
クラスに@Validatedを付ける
@Service
@Validated
public class UserService {
public void register(@NotBlank(message = "名前は必須です") String name,
@Email(message = "メール形式が不正です") String email) {
// 登録処理
}
}
これだけで、register()に不正な値が渡されたときに例外が発生します。メソッドの境界で止められるので、呼び出し元が増えても安全性が上がります。
DTOを引数にする場合もOK
@Service
@Validated
public class UserService {
public void register(@Valid UserRequest request) {
// DTOの制約アノテーションに従って検証される
}
}
ここは少し紛らわしいのですが、メソッドバリデーションを有効にする「スイッチ」が@Validatedで、DTOの中身を再帰的に検証する「合図」として@Validを併用する、というイメージです。
戻り値も検証できる
戻り値の制約も付けられます(例えば「必ず返す」契約を守らせたいときに便利です)。
@Service
@Validated
public class TokenService {
public @NotBlank(message = "トークンが空です") String issueToken(@NotBlank String userId) {
return "token";
}
}
例外の種類とハンドリングの考え方
@Validatedを使うと、どこで検証が走ったかによって例外が変わります。よく見るのは次の2つです。
| どこで失敗したか | よく発生する例外 | 典型的なケース |
|---|---|---|
@RequestBodyのDTO検証 | MethodArgumentNotValidException | JSON→DTOのバリデーション(@Validated/@Validどちらでも) |
| メソッドバリデーション | ConstraintViolationException | Serviceの引数/戻り値、@RequestParamや@PathVariableの制約など |
ControllerAdviceでまとめて返す例
まずは最低限、メッセージを拾って返す形の例です(プロジェクトに合わせてレスポンス形式を整えてください)。
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public Map<String, Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
var errors = ex.getBindingResult().getFieldErrors().stream()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.toList();
return Map.of(
"type", "validation_error",
"errors", errors
);
}
@ExceptionHandler(ConstraintViolationException.class)
public Map<String, Object> handleConstraintViolation(ConstraintViolationException ex) {
var errors = ex.getConstraintViolations().stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.toList();
return Map.of(
"type", "constraint_violation",
"errors", errors
);
}
}
@Validatedでハマりがちなポイント
Spring Boot 3.2以降はControllerの引数検証で発生する例外が変わる
Spring Boot 3.2(Spring Framework 6.1)からは、@RequestParamや@PathVariableに付けた制約のように、Controllerのメソッド引数へ直接付けた制約は Spring MVC の組み込みメソッドバリデーションで検証されるようになりました。この経路で失敗すると、従来のConstraintViolationExceptionではなくHandlerMethodValidationExceptionが投げられます。
@GetMapping("/users")
public List<UserResponse> search(@RequestParam @Size(min = 2) String keyword) {
// Spring Boot 3.2以降は失敗すると HandlerMethodValidationException になる
return List.of();
}
この組み込み検証が有効になるのは、Controllerクラスに@Validatedを付けていない場合です。クラスに@Validatedを付けると従来どおりプロキシによるメソッドバリデーションが優先され、ConstraintViolationExceptionが発生します。既存の@RestControllerAdviceがConstraintViolationExceptionしか拾っていないと、アップグレード後は自作のハンドラを通らずに Spring 標準の 400 レスポンスが返ってしまい、エラー形式がそろわなくなります。3.2以降ではHandlerMethodValidationExceptionのハンドラも足しておきましょう。
@ExceptionHandler(HandlerMethodValidationException.class)
public Map<String, Object> handleHandlerMethodValidation(HandlerMethodValidationException ex) {
var errors = ex.getAllErrors().stream()
.map(MessageSourceResolvable::getDefaultMessage)
.toList();
return Map.of(
"type", "validation_error",
"errors", errors
);
}
メソッドバリデーションは「Spring管理のBean越し」に呼ばれて初めて効く
メソッドバリデーションは、Springの仕組み(プロキシ)で呼び出しを横取りして検証します。つまり、同じクラス内でthis.register(...)のように呼ぶと検証が走らないことがあります。
- OK:Controller → Service(Spring管理Bean)呼び出し
- 注意:ServiceのメソッドA → 同じServiceのメソッドB(自己呼び出し)
設計として「検証したい境界(公開API)」をServiceの外から呼ぶ形にしておくと、安全に運用できます。
グループを指定したときは「そのグループに属する制約だけ」が動く
@Validated(OnCreate.class)で検証すると、groupsを指定していない制約(デフォルトグループ)は動かないことがあります。意図的に分けたいときは便利ですが、「あれ、@NotBlankが効かないぞ?」となりやすいポイントです。
グループ運用を始めたら、DTO側の制約にgroupsを付ける方針をそろえると混乱しにくいです。
Group Sequenceで検証順序を制御する
「必須チェックに通った場合だけ重い検証を走らせたい」ケースでは、@GroupSequenceが有効です。例えば、空チェックに失敗しているのにDB参照を伴うカスタム制約まで走ってしまうと、エラーメッセージが無駄に増えますし、処理時間ももったいないですよね。
順序を表すグループを定義する
public interface BasicChecks {}
public interface BusinessChecks {}
@GroupSequence({BasicChecks.class, BusinessChecks.class})
public interface OrderedChecks {}
OrderedChecks自体は制約を持たない、ただの並び順です。BasicChecksに属する制約がすべて通ったときだけ、BusinessChecksの制約が評価されます。
DTO側で制約を各グループに振り分ける
public class OrderRequest {
@NotBlank(message = "商品コードは必須です", groups = BasicChecks.class)
private String productCode;
@NotNull(message = "数量は必須です", groups = BasicChecks.class)
@Min(value = 1, message = "数量は1以上で指定してください", groups = BusinessChecks.class)
private Integer quantity;
// getter/setter
}
Controllerでは順序グループを指定する
Controller側では、個別のグループではなく@GroupSequenceを付けたインターフェースを指定します。
@PostMapping("/orders")
public String create(@RequestBody @Validated(OrderedChecks.class) OrderRequest request) {
return "accepted";
}
productCodeが空の状態でリクエストするとBasicChecksの段階で止まるので、@Minのメッセージは返りません。必須項目を埋めた次のリクエストで初めてBusinessChecksが動きます。エラーが段階的に返るので、フロント側の表示もすっきりしますよ。
デフォルトグループとの関係に注意する
groupsを書かなかった制約はDefaultグループ(jakarta.validation.groups.Default)に属します。OrderedChecksの並びにDefault.classを含めていないと、groupsなしの制約は一切動きません。既存のDTOに後から順序を足すときは、次のようにDefault.classを先頭に入れておくと安全です。
@GroupSequence({Default.class, BusinessChecks.class})
public interface OrderedChecks {}
なお、DTOクラス自体に@GroupSequenceを付けて、そのクラスのデフォルトグループの順序を差し替える書き方もあります。ただしその場合は並びの中に自クラスを含める必要があり少し読みづらくなるので、まずは上記のようにインターフェースで順序を定義する形から始めるのがおすすめです。
返却フォーマットを統一する運用
Controllerの@ValidatedとServiceの@Validatedが混在すると、例外種別ごとに返却形式がぶれやすいです。
API運用では次の方針で揃えると扱いやすくなります。
- 例外種別に関係なくレスポンス形式を共通化する
codeとerrorsを固定キーにするerrors内にpath(項目)とmessage(説明)を持たせる
これだけでフロントエンドや他サービス連携時の実装コストを大きく減らせます。
まとめ
@Validatedは、Spring Bootでバリデーションを一段階レベルアップさせたいときに便利なアノテーションです。特に、登録と更新でルールを切り替える グループバリデーション と、Service層の境界で守りを固める メソッドバリデーション は、実務でかなり効果的です。
まずはControllerでのグループ切り替えから導入し、必要に応じてService層にも@Validatedを広げていくと、無理なく堅牢な設計にしていけます。
ぜひプロジェクトの規模や運用に合わせて、@Validと@Validatedを使い分けてみてください。