AIによる要約
BindingResultは、@Validで検出した入力エラーをControllerで受け取るための引数です。重要なのは、@Validを付けたFormの直後に置くことです。順番を間違えると、期待通りにエラー処理できません。


Spring MVCで入力チェックを実装すると、@Validと一緒にBindingResultが出てきます。
利用者登録のControllerに Model を足したところ、利用枠0を送ると入力画面へ戻らず400になったとします。同じフォーム、同じ入力で、BindingResult の順番だけが違う例を試します。
この記事のポイント
BindingResultは入力チェック結果を受け取るために使う@Validを付けたFormの直後に置く- エラーがあればServiceへ進まず入力画面に戻す
- REST APIでは例外ハンドリングに寄せる現場もある
実行用サンプル
同じ不正POSTを送り、引数の順番による200と400の違い、登録Serviceの呼び出し0回を確認します。 ソース全文、JUnitの検証コード、curlの実行ファイル、pom.xml、READMEを収録しています。JDK 17とMaven 3.9系で mvn test を実行します。Spring Boot 3.5.16のサンプルです。
BindingResultは入力チェック結果を受け取る
BindingResult は型変換などのバインドエラーと、@Valid で検出した制約違反を保持します。利用枠 abc は型変換で、整数の 0 は @Min(1) でエラーになります。
UserRegistrationController.java の抜粋。入力エラーをフォームの直後で受け取る
@PostMapping("/users")
public String register(@Valid @ModelAttribute("userRegisterForm") UserRegisterForm form,
BindingResult errors, Model model) {
if (form.getDepartment() != null && !form.getDepartment().isBlank()
&& DEPARTMENTS.stream().noneMatch(department -> department.code().equals(form.getDepartment()))) {
errors.rejectValue("department", "unknownDepartment", "一覧から部署を選択してください");
}
if (errors.hasErrors()) {
model.addAttribute("showCorrectionNotice", true);
return "users/new";
}
userService.register(form.getName(), form.getDepartment(), form.getSeatLimit());
return "redirect:/users/new?registered";
}bindingResult.hasErrors()がtrueなら、入力値に問題があります。この時点でServiceへ進まず、入力画面へ戻すのが基本です。
@Validの直後に置く理由
最重要ポイントは、BindingResultを@Valid対象の直後に置くことです。Springは、直前の検証対象に対するエラー結果としてBindingResultを扱います。
上の引数は Form → BindingResult → Model の順です。画面用の Model は BindingResult の後ろに置けます。
この順番では、引数を準備する段階で検出したエラーを errors に渡し、Controllerの本体が実行されます。利用枠 0 を送ると、HTTP 200の登録画面に 0 と「利用枠は1以上で入力してください」が表示されます。
順番を間違えるとどうなるか
よくあるミスは、@ValidとBindingResultの間に別の引数を挟むことです。
UserRegistrationController.java の抜粋。引数の間にModelを挟んだ失敗例
@PostMapping("/demo/users/wrong-order")
public String wrongOrder(@Valid @ModelAttribute("userRegisterForm") UserRegisterForm form,
Model model, BindingResult errors) {
return register(form, errors, model);
}間に Model を挟むと、Spring MVCはFormの直後にエラーを受け取る引数がないと判定します。利用枠 0 では、Controllerの本体が実行される前に MethodArgumentNotValidException になり、このサンプルではHTTP 400を返します。
展開したプロジェクトで mvn spring-boot:run を実行し、別のターミナルで同じディレクトリから次を試します。上が正しい順で200、下が誤った順で400です。
compare.sh の抜粋。同じPOSTで引数の順序の違いを比較
base_url="${BASE_URL:-http://localhost:8080}"
# 正しい順番はHTTP 200。利用枠0のエラーを画面に返す
curl -si "$base_url/users" \
--data-urlencode 'name=Sato' \
--data-urlencode 'department=OPS' \
--data-urlencode 'seatLimit=0'
# 誤った順番はHTTP 400。Controllerの処理に入る前に失敗する
curl -si "$base_url/demo/users/wrong-order" \
--data-urlencode 'name=Sato' \
--data-urlencode 'department=OPS' \
--data-urlencode 'seatLimit=0'エラーがあればServiceへ進まない
形式チェックでエラーがある状態でServiceを呼ぶと、Service側で想定外の値を扱うことになります。Controllerでは、エラーの有無を見て処理を止めます。
UserRegistrationController.java の抜粋。エラーがある時点で登録を止める部分
if (errors.hasErrors()) {
model.addAttribute("showCorrectionNotice", true);
return "users/new";
}
userService.register(form.getName(), form.getDepartment(), form.getSeatLimit());同じ seatLimit=0 で、正しい順番は画面を返し、誤った順番は例外になります。どちらも userService.register の呼び出しは0回です。誤った順番でも seatLimit=2 なら302で登録に進むため、レビューでは不正値も送って確かめます。
Thymeleafでエラーを表示する
画面側では、Formのフィールドに紐づくエラーを表示します。
new.html の抜粋。利用枠の入力値とエラーを表示する部分
<label>利用枠 <input type="text" inputmode="numeric" th:field="*{seatLimit}"></label>
<span th:errors="*{seatLimit}"></span>テンプレートは userRegisterForm を参照し、th:field で利用枠の入力を残し、th:errors で同じ項目のメッセージを表示します。BindingResult を正しく受けた後は、フォームを作り直さず、そのリクエストのままビューを返します。
REST APIでは扱い方が変わることもある
画面フォームではBindingResultを使って入力画面に戻す実装が分かりやすいです。一方、REST APIでは、入力エラーを例外ハンドラでまとめてJSONレスポンスにする現場もあります。
JSONを受けるAPIでは、@Valid @RequestBody の制約違反を例外として処理し、HTTP 400を返す構成があります。画面フォームを同じビューで再描画する今回の例とは、応答の役割が異なります。
エラーのJSON形式を決めるときは、APIで使う共通のエラー処理を確認します。JSONの入力チェックを切り分ける方法は、RequestBodyで受信したJSONが400になる原因で補足しています。
レビュー前のセルフチェック
確認リスト
- Formの直後に
BindingResultがあり、Modelを挟んでいないか - 利用枠0で入力とメッセージが画面に残り、登録Serviceの呼び出しが0回か
- 画面側でForm名とフィールド名が一致しているか
- APIと画面フォームでエラー処理を混同していないか
次は誤った順番のテストです。400、例外、フィールドの制約エラー、登録が呼ばれていないことを確認します。
UserRegistrationTest.java の抜粋。Modelを間に置いた失敗を検証
@Test
void modelBetweenFormAndBindingResultMakesInvalidPostFailBeforeHandlerBody() throws Exception {
var result = mvc.perform(post("/demo/users/wrong-order").formField("name", "Sato")
.formField("department", "OPS").formField("seatLimit", "0"))
.andExpect(status().isBadRequest())
.andExpect(handler().methodName("wrongOrder"))
.andReturn();
assertThat(result.getResolvedException())
.isInstanceOfSatisfying(MethodArgumentNotValidException.class, error ->
assertThat(error.getBindingResult().getFieldError("seatLimit").getCode()).isEqualTo("Min"));
assertThat(service.calls).isEmpty();
}両記事に共通するサンプル全体のテスト結果です。
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0この記事とあわせて読みたい
まとめ
BindingResultは、@Validの結果をControllerで受け取るためのものです。最も大事なのは、@Validを付けた引数の直後に置くことです。
新人のうちは、順番、エラー時にServiceへ進まないこと、画面に戻したときにエラーが表示されること。この3点を押さえれば十分実務で使えます。



