「@Valid付けたのに、なんで普通に通っちゃうんですか…?」とか、「急に400返ってきて何が悪いのか分からないんですが…」みたいなやつ、Spring Bootでバリデーション(入力チェック)を触るとめちゃくちゃ出ますよね。
あと地味に多いのが、入れ子DTOだけスルーされるとか、Listの中身だけ素通りするパターンです。こっちは見た目ちゃんと書けてるのに、動かないと本当にモヤっとします。
この記事では、そのモヤモヤをちゃんと終わらせます。
やることはシンプルで、まずはコピペで動く最小例で「これが正しい形」を体験してもらいます。
次に、動かないときのための原因チェックリストで「どこが抜けがちか」を一気に潰します。
そのうえで、入れ子DTO・配列・Listの「素通り」を止めるコツ、そして実務で一番困るエラーの返し方(画面/WebとAPI/JSON)まで、ちゃんとテンプレでまとめます。
最後に、@Validatedの使いどころ(場面切替)や、メッセージの日本語化もやります。
読み終わる頃には、「@Validが動かない」「入れ子が効かない」「エラーを綺麗に返せない」が全部セットで解決して、自分のプロジェクトにそのまま持ち込める“型”ができている状態にしますね。
まずはコピペでOK!最小の動くバリデーション
ここは難しい話いったんナシです。
「依存関係を入れる → DTOにルールを書く → Controllerで@Validを付ける」これだけで動きます。
まずはコピペして、動いた感を掴んでくださいです!
依存関係:starter-validation
Spring Bootで入力チェックを動かすには、まずこれが必要です。
これが入ってないと、@Validを付けても基本的に何も起きません(あるある最上位です…)。
Maven(pom.xml)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Gradle(build.gradle)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
}
裏側ではだいたい Hibernate Validator(無料) が動いてくれて、アノテーションのルールをチェックしてくれる感じです。
DTOに付ける:よく使う基本アノテーション
最小例として「ユーザー登録っぽいDTO」を作ります。
この3つ(name/email/password)で、だいたいの現場の入口はカバーできます。
package com.example.demo.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class SignupRequest {
@NotBlank(message = "名前は必須です")
@Size(max = 20, message = "名前は20文字以内にしてください")
private String name;
@NotBlank(message = "メールは必須です")
@Email(message = "メールの形式が変です")
private String email;
@NotBlank(message = "パスワードは必須です")
@Size(min = 8, message = "パスワードは8文字以上にしてください")
private String password;
// getter/setter(またはrecordでもOK)
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}
ポイントはこれだけです。
Controllerに付ける:@Validが「スイッチ」
DTOにルールを書いただけでは、まだチェックが走らないことがあります。
Controllerの引数で @Valid を付けて初めて「チェックして!」の合図になります。
package com.example.demo.controller;
import com.example.demo.dto.SignupRequest;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
public class SignupController {
@PostMapping("/signup")
public ResponseEntity<String> signup(@RequestBody @Valid SignupRequest request) {
// ここに来た時点で、バリデーションOKのデータです
return ResponseEntity.ok("ok");
}
}
ここで大事なのは配置です。
動いたか確認:OK/NGのリクエスト例
動作確認は curl で十分です(PostmanでももちろんOKです)。
OK例(通る)
curl -X POST http://localhost:8080/api/signup \
-H "Content-Type: application/json" \
-d '{"name":"たろう","email":"taro@example.com","password":"password123"}'
期待:ok が返る(200)
NG例(落ちる)
curl -X POST http://localhost:8080/api/signup \
-H "Content-Type: application/json" \
-d '{"name":"","email":"xxx","password":"123"}'
期待:だいたい 400 Bad Request になります。
この段階だとエラーの見え方は環境によって少し違いますが、「nameが必須」「email形式が違う」「password短い」みたいな内容が返る(or ログに出る)はずです。
「え、なんで勝手に400になるんです?」は超あるあるなので、H2-4で“仕組み”からスッキリ説明しますね。
そして「JSONを綺麗に整形して返すテンプレ」もそこで出します。
「@Validが動かない」原因チェックリスト(ここを見れば直る)
「ちゃんと@Valid付けたのに、普通に通るんですが…?」ってときは、だいたい原因は決まってます。
ここはYES/NOで潰せるチェックリストにするので、上から順に見ていけば直るはずです!
依存関係が入ってない/importが違う
Q1. spring-boot-starter-validation 入ってます?
Q2. import が jakarta.validation.Valid になってます?
よくあるNG例:
import javax.validation.Valid; // ←古い(環境によっては動かない/混ざると混乱)
基本はこっちです:
import jakarta.validation.Valid;
IntelliJやVS Codeで自動import任せにすると、たまに違う方が入ることがあるので、ここは一回だけ目視チェックおすすめです!
付ける場所が違う(Controller引数/入れ子/List)
Q3. Controllerの引数に@Valid付けてます?(DTOに付けただけで満足してない?)
OK例(これが基本):
public ResponseEntity<?> signup(@RequestBody @Valid SignupRequest request) { ... }
ありがちNG例(DTOに注釈はあるけど、チェック開始してない):
public ResponseEntity<?> signup(@RequestBody SignupRequest request) { ... } // @Validなし
Q4. 入れ子DTOがあるなら「親のフィールド」に@Valid付けてます?
例(NG):
public class ParentDto {
private ChildDto child; // ← ここに@Validがないと、ChildDtoの中はチェックされません
}
例(OK):
public class ParentDto {
@jakarta.validation.Valid
private ChildDto child;
}
Q5. Listの中身をチェックしたいなら「要素側」に@Valid付けてます?
よくある勘違い(外側だけ):
private List<ItemDto> items; // ← 中身のItemDtoの注釈が実行されないことがある
定番の書き方(要素を@Valid):
private List<@jakarta.validation.Valid ItemDto> items;
※ 入れ子/Listの話は 次で「最小コード+組み合わせ例」をガッツリやります!
例外が握りつぶされている/ログが見えていない
Q6. バリデーションエラーが出てるのに、例外ハンドリングで“無かったこと”にしてません?
ありがちなパターンはこれです:
まずは最低限、これだけ意識すると速いです。
「JSONをどう整形するのが正解?」は この後にテンプレを用意しています。
ここで悩むの、普通です。悩まなくてOKです!
まとめ:最速で直すなら、ここだけ見てください
最後に超まとめです。動かない時はほぼこれ。
- starter-validation入ってる?
- importはjakarta?
- Controller引数に@Valid付いてる?
- 入れ子の親フィールドに@Valid付いてる?
- Listは要素側に@Valid付いてる?
- 例外がどこかで握りつぶされてない?



次は、入れ子DTO・配列・Listの「素通り」を完全に止める書き方を、結論→最小コードでいきますね。
入れ子DTO・配列・Listのチェックを通すコツ
ここ、結論だけ先に言いますね。この2つが“素通り防止のルール”です。
これがないと、子どものDTOがどれだけルール書いてても「見ないでスルー」されがちです…!
入れ子DTO:親のフィールドに@Validを付ける
まず入れ子の最小例です。
「住所(Address)がユーザー(User)の中に入ってる」みたいな形ですね。
子(入れ子側)
package com.example.demo.dto;
import jakarta.validation.constraints.NotBlank;
public class AddressDto {
@NotBlank(message = "郵便番号は必須です")
private String zip;
@NotBlank(message = "住所は必須です")
private String line1;
public String getZip() { return zip; }
public void setZip(String zip) { this.zip = zip; }
public String getLine1() { return line1; }
public void setLine1(String line1) { this.line1 = line1; }
}
親(ここが重要)
package com.example.demo.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
public class UserRequest {
@NotBlank(message = "名前は必須です")
private String name;
@Valid // ← これがないと AddressDto の中の @NotBlank が動かないことが多いです
private AddressDto address;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public AddressDto getAddress() { return address; }
public void setAddress(AddressDto address) { this.address = address; }
}
ポイント:@Valid は「その中も見てチェックしてね」の合図です。
親に付けないと、子の注釈は読まれない=入れ子だけ素通り、になりやすいです。
Listの中身:List<@Valid ItemDto>の形にする
次は「注文(Order)に明細(items)がある」みたいなやつです。
ここでの落とし穴は、List自体は見ても、中身のDtoを見ないパターンです。
要素(Item)
package com.example.demo.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public class ItemDto {
@NotBlank(message = "商品名は必須です")
private String itemName;
@Min(value = 1, message = "数量は1以上にしてください")
private int quantity;
public String getItemName() { return itemName; }
public void setItemName(String itemName) { this.itemName = itemName; }
public int getQuantity() { return quantity; }
public void setQuantity(int quantity) { this.quantity = quantity; }
}
親(Order)— Listの要素側に@Valid
package com.example.demo.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
public class OrderRequest {
@NotEmpty(message = "明細は1件以上必要です") // ← 「要素数」のチェック(外側)
private List<@Valid ItemDto> items; // ← 「中身」のチェック(内側)
public List<ItemDto> getItems() { return items; }
public void setItems(List<ItemDto> items) { this.items = items; }
}
ポイントは二段構えです。
「要素数」も「要素の中身」も両方チェックする例
じゃあ実際に、どんなリクエストが落ちるの?をまとめて見せますね。
NG①:Listが空(外側ルール違反)
{
"items": []
}
NG②:Listはあるけど中身がダメ(内側ルール違反)
{
"items": [
{ "itemName": "", "quantity": 0 }
]
}
OK:外側も内側もOK
{
"items": [
{ "itemName": "えんぴつ", "quantity": 2 }
]
}
どこに付けるか迷った時の「図っぽい覚え方」
コード見るより、場所だけ覚えるならこれです。
つまり、「深いところを見たい場所に@Valid」って覚えるとラクです!

次は、バリデーションで落ちたときに「どう返すのが正解?」問題を片付けますね。
画面用(BindingResult)とAPI用(JSON整形)で、テンプレ2本立ていきます。
エラーの返し方2パターン:画面(Web)とAPI(JSON)
バリデーションが動いた次に絶対つまずくのがここです。
「落ちたのは分かったけど、どう返すのが正解なんです?」問題ですね。
結論、よく使うのはこの2つです。
画面向け:BindingResultで受け取って同じ画面へ
画面(HTML)なら、基本は「入力画面に戻してエラー表示」です。
そのための受け皿が BindingResult です。
✅ 最小例(フォーム送信 → エラーなら同じ画面へ)
package com.example.demo.web;
import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
@Controller
@RequestMapping("/signup")
public class SignupPageController {
@GetMapping
public String showForm(SignupForm form) {
return "signup";
}
@PostMapping
public String submit(@ModelAttribute @Valid SignupForm form,
BindingResult bindingResult, // ← ここが受け皿
Model model) {
if (bindingResult.hasErrors()) {
// エラーがあるなら同じ画面に戻す(テンプレ側で表示)
return "signup";
}
// 登録処理など
return "redirect:/signup/complete";
}
}
超重要ポイント:BindingResult は @Validを付けた引数の“直後”に置くのが定番です。
これがあると「例外で落とす」じゃなくて「エラーとして受け取る」動きになります。
API向け:例外を拾ってエラー一覧JSONに整形
API(JSON)だと、同じ画面に戻せないので、フロントが扱いやすいエラーJSONを返すのが王道です。
Spring Bootはそのままだと400で落としてくれるんですが、返る形がバラつくので、Adviceで統一します。
✅ テンプレ(field別に配列で返す+globalも返す)
package com.example.demo.api;
import org.springframework.http.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.time.OffsetDateTime;
import java.util.*;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handle(MethodArgumentNotValidException ex) {
Map<String, List<String>> fieldErrors = new LinkedHashMap<>();
List<String> globalErrors = new ArrayList<>();
ex.getBindingResult().getFieldErrors().forEach(err -> {
fieldErrors.computeIfAbsent(err.getField(), k -> new ArrayList<>())
.add(err.getDefaultMessage());
});
ex.getBindingResult().getGlobalErrors().forEach(err -> {
globalErrors.add(err.getDefaultMessage());
});
Map<String, Object> body = new LinkedHashMap<>();
body.put("message", "入力内容にエラーがあります");
body.put("errors", fieldErrors); // ← {field:[msg...]}
body.put("global", globalErrors); // ← 全体エラーがある場合
body.put("timestamp", OffsetDateTime.now().toString());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
}
返るイメージはこんな感じです(例):
{
"message": "入力内容にエラーがあります",
"errors": {
"name": ["名前は必須です"],
"email": ["メールの形式が変です"],
"password": ["パスワードは8文字以上にしてください"]
},
"global": [],
"timestamp": "2025-12-21T10:12:34+09:00"
}
400になる理由:自動で弾かれる仕組みを理解する
「なんで勝手に400になるんです?」の答えはこれです。
なので、
っていう使い分けになります。

次は、@Valid と @Validated の違いを「暗記」じゃなく「使い分け」で理解できるようにしますね。
作成だけ必須/更新は任意、みたいな 場面切替(グループ)を最小例でいきます。
@Validと@Validatedの違いを一言で+場面で使い分け
一言まとめ:@Validは基本、@Validatedは「場面切替」
普段は@ValidでOKで、「ルール切替が必要になったら@Validated」って覚え方が一番ラクです。
次は、@Valid と @Validated の違いを「暗記」じゃなく「使い分け」で理解できるようにしますね。
作成だけ必須/更新は任意、みたいな 場面切替(グループ)を最小例でいきます。
作成だけ必須/更新は任意:グループバリデーション
まず“グループ名”を作ります(ただの目印です)。
public interface Create {}
public interface Update {}
DTO側で「どの場面で効かせるか」を指定します。
import jakarta.validation.constraints.*;
public class UserDto {
@NotNull(groups = Update.class, message = "更新ではidが必須です")
private Long id;
@NotBlank(groups = {Create.class, Update.class}, message = "名前は必須です")
private String name;
@NotBlank(groups = Create.class, message = "作成ではパスワード必須です")
@Size(min = 8, groups = Create.class, message = "作成では8文字以上にしてください")
private String password;
}
Controllerで「今回はどの場面?」を選びます。
import org.springframework.validation.annotation.Validated;
@PostMapping("/users")
public void create(@RequestBody @Validated(Create.class) UserDto dto) {}
@PutMapping("/users/{id}")
public void update(@RequestBody @Validated(Update.class) UserDto dto) {}
これで「作成はpassword必須、更新はpassword任意」みたいな切替ができます。
Serviceでもチェック:クラスに@Validatedを付ける
Controllerだけじゃなく、Serviceの入口でも守ると事故が減ります(Controller以外から呼ばれる道が増えがちなので…)。
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.Valid;
@Service
@Validated
public class UserService {
public void register(@Valid UserDto dto) {
// ここに来る前にチェックが走ります(外部から呼ばれた場合)
}
}
※Serviceのチェックは「Springの管理するBean越しに呼ばれる」ときに効くイメージでOKです(同じクラス内で自分自身を呼ぶ形だと効かないことがあります)。
よく使うアノテーション早見表(迷ったらここ)
「NotNullとNotBlank、どっちだっけ…」って毎回なりますよね。大丈夫です。
ここは“いつ使う?”でサクッと選べるようにまとめます。迷ったらこの章に戻ってくればOKです!
文字:NotNull/NotBlank/NotEmptyの違い
まず結論だけ言うと、こんな感じです。
| アノテーション | 主に使う対象 | NGになる例 | OKになる例 | ありがちなミス |
|---|---|---|---|---|
@NotNull | なんでも | null | "" / [] / 0 | 「必須」のつもりで文字列に使って、空文字が通る |
@NotEmpty | 文字・List・配列 | "" / [] | "a" / [1] | 文字列で「スペースだけ」は通ることがある |
@NotBlank | 文字列専用 | null / "" / " " | "a" | 数値やListには使えない |
おすすめの覚え方
長さ・数:Size/Min/Max(ざっくり選び方)
ここは「文字数」と「数値」で使い分けです。
| アノテーション | 使う対象 | 例 | よくある使い方 |
|---|---|---|---|
@Size(min=, max=) | 文字列 / List / 配列 | @Size(max=20) | 名前は20文字まで、明細は1〜50件まで |
@Min(1) | 数値 | @Min(1) | 数量は1以上、年齢は0以上など |
@Max(100) | 数値 | @Max(100) | 上限がある数字(点数、割引率など) |
ミスりがちポイント
形式:Email/Pattern(やりすぎ注意ポイント)
形式チェックは便利なんですが、やりすぎると利用者が詰みます。ほどほどが正解です。
| アノテーション | 使いどころ | 注意 |
|---|---|---|
@Email | メール入力 | 厳密すぎると弾きすぎることもあるので、必須は@NotBlankとセットにすることが多いです |
@Pattern(regexp="...") | 郵便番号、英数字のみ、など | ルールが厳しすぎると「正しいのに弾かれる」地獄になります。まずはゆるめ推奨です |
例:郵便番号を「数字とハイフンだけ」にしたい場合(ゆるめ)
@Pattern(regexp = "^[0-9-]+$", message = "郵便番号は数字とハイフンで入力してください")
private String zip;
迷ったらこの選び方でOKです
最後に、現場での“即決ルール”だけ置いておきますね。

次は、エラーメッセージを日本語にしたり、文言を統一して運用しやすくする話に行きますね。
「message直書き → properties管理 → JSONテンプレ」の順でサクッと固めます。
エラーメッセージを日本語にする・分かりやすくする
バリデーション自体は動いたけど、「エラー文が英語っぽい」「項目ごとに言い方バラバラ」みたいになると、地味に運用がしんどいです。ここで“読みやすい日本語”に揃える型を作っちゃいましょうです。
message=””で手早く変更(まずはここ)
一番早いのは、アノテーションにそのまま書く方法です。
@NotBlank(message = "氏名は必須です")
@Size(max = 20, message = "氏名は20文字以内にしてください")
private String name;
とにかく今すぐ整えたいならこれでOKです。
ただ、増えてくると「同じ文言をあちこちで修正」が発生しやすいので、次の方法が運用向きです。
messages.propertiesで一括管理(日本語化に強い)
おすすめは messages.propertiesに文言を集めるやり方です。
DTO側は「キーを参照するだけ」にします。
@NotBlank(message = "{user.name.required}")
private String name;
@Email(message = "{user.email.invalid}")
private String email;
src/main/resources/messages.properties にこう書きます。
user.name.required=氏名は必須です
user.email.invalid=メールアドレスの形式が正しくないです
これだと、文言を変えたくなっても propertiesだけ直せば全体に反映できて楽です。
規模が大きくなったら翻訳管理ツールを検討してもいいですが、まずはpropertiesで十分戦えます。
JSONの返し方テンプレ:field別に配列で返す
フロントが扱いやすいのは、だいたいこの形です。
{
"message": "入力内容にエラーがあります",
"errors": {
"name": ["氏名は必須です"],
"email": ["メールアドレスの形式が正しくないです"]
},
"global": []
}
ポイントは2つです。
このJSON形式をチームで固定できると、フロントもバックも一気に楽になりますです。
つまずきQ&A(検索されがちな疑問を一気に解決)
ここは「みんなが同じところでコケるやつ」をまとめて潰しますね。
フォーマットは 質問 → 結論 → 理由 → 最小例 でいきます!
BindingResultって何?付けないと例外?
- QBindingResultって何です?付けないとどうなります?
- A
結論:
画面(Web)のとき、例外にせず“エラーとして受け取るための受け皿”です。付けないと、基本は例外になって400になったり、エラーページに飛んだりします。理由:
@ValidでチェックがNGになると、Springは「入力が不正」として例外を投げます。
でも画面の場合は「同じ画面に戻してエラー表示したい」ので、例外で落ちるより BindingResultに貯めて表示が便利なんです。最小例:
@PostMapping("/signup") public String submit(@ModelAttribute @Valid SignupForm form, BindingResult bindingResult) { // ←これが受け皿 if (bindingResult.hasErrors()) { return "signup"; // 同じ画面へ } return "redirect:/signup/complete"; }※API(JSON)ならBindingResultは基本使わず、
@RestControllerAdviceで例外を拾って整形するのが王道です。
入れ子がチェックされないのはなぜ?
- Q子DTOに@NotBlankとか付けてるのに、入れ子だけチェックされません。なんで?
- A
結論:
親のフィールドに@Validが付いてないのが原因のことがほとんどです。理由:
@Validは「その中も見てチェックしてね」というスイッチです。
親がスイッチを押してないと、子の注釈は読まれません(=素通り)。最小例:
public class ParentDto { @Valid // ←これがないとChildDtoの中が動かない private ChildDto child; } public class ChildDto { @NotBlank(message="子のnameは必須です") private String name; }Listも同じで、
List<@Valid ItemDto>にしないと中身が見られないことがあります(H2-3の型です)。
ControllerとService、どっちでやるのが正解?
- QバリデーションってControllerでやるのが正解ですか?Serviceですか?
- A
結論:
基本は Controllerで入口チェック、必要なら Serviceでも入口チェックの二段構えが強いです。理由:
最小例:
@Service @Validated public class UserService { public void register(@Valid UserDto dto) { // ここに来る前にチェックが走る想定 } }迷ったらこの方針でOKです。

次はいよいよまとめです。この記事のチェックリストをもう一回ぎゅっと再掲して、「明日やること」に落としますね。
まとめ:今日から困らないための「型」を作ろう
ここまでで、Spring Bootのバリデーションで困りがちなところは一通り片付きました。
最後に「もう迷わないための型」を、チェックリストで再掲しますね。
この記事のチェックリスト再掲
困ったらこの順で見れば、だいたい直りますです。
次にやると良いこと(テスト/共通エラー形式)
明日やることを3つに絞るなら、これがおすすめです。
- 最小テンプレを自分のプロジェクトにコピペして当てはめる
DTO+Controller+Advice(エラーJSON)を“型”として置いちゃうのが一番早いです。 - 入れ子/Listの付け忘れを総点検する
親に@Valid、List要素に@Valid、外側@NotEmpty…ここが抜けると再発します。 - エラー形式を共通クラス化してテストを足す
「errorsはfield→配列」「globalも持つ」を固定して、テストで崩れないようにすると運用が楽になりますです。
このページの最小テンプレ(DTO+Controller+Advice)をコピペして、あなたのAPIのDTOに当てはめてみてください。
これだけで、明日からの「動かない・返せない・入れ子が無視される」が激減しますよ。
