APIで空の件名を送ると400になる。ところが画面側では、どの欄を直せばよいか分からない。ステータスコードだけ合っていても、入力画面を作る側には足りません。
私なら、エラー応答を利用者が修正できる項目名とメッセージだけに絞った契約にします。スタックトレース、例外クラス名、送信された秘密値を返す必要はありません。ここでは Java 21、Spring Boot 3.5系、Spring MVC の @Valid @RequestBody を例にします。
応答形式を先に決める

件名を必須にした、架空のタスク登録APIです。空文字なら次の応答にします。
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"code": "VALIDATION_ERROR",
"fields": {
"title": "件名を入力してください"
}
}code は画面側が処理を分けるための固定値です。fields のキーは入力項目の名前で、今回の例では title です。日本語メッセージは表示用であり、プログラムの分岐条件には使いません。同じ項目に複数制約が違反したときは、ここでは最初に取得した1件を表示します。どの制約が先になるかへ依存せず、表示の優先順位が必要なら明示的に決めてください。全件必要なAPIなら Map<String, List<String>> に契約を変えます。
DTOとコントローラーを作る
spring-boot-starter-validation を依存関係に入れます。@NotBlank は null・空文字・空白だけの文字列を拒否します。
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
record TaskInput(@NotBlank(message = "件名を入力してください") String title) {}
@RestController
class TasksController {
@PostMapping("/tasks")
ResponseEntity<Void> create(@Valid @RequestBody TaskInput input) {
// ここへ到達した入力を保存する
return ResponseEntity.noContent().build();
}
}この形の引数検証に失敗すると MethodArgumentNotValidException が発生し、Spring MVCは標準で400を返します。ここから自分たちのJSON形式へ変換する部分を追加します。
RestControllerAdviceで項目別エラーへ変換する
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
record ApiValidationError(String code, Map<String, String> fields) {}
@RestControllerAdvice
class ApiValidationAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiValidationError> onInvalid(MethodArgumentNotValidException ex) {
Map<String, String> fields = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
fields.putIfAbsent(error.getField(),
error.getDefaultMessage() == null
? "入力を確認してください" : error.getDefaultMessage()));
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ApiValidationError("VALIDATION_ERROR", fields));
}
}getRejectedValue() や ex.getMessage() をそのまま返していないのがポイントです。パスワードや個人情報の入力値、内部実装をレスポンスへ混ぜないためです。クラス単位の相関チェックも扱うAPIなら、getGlobalErrors() を別の errors 配列へ変換するか、制約側で修正すべき項目へ違反を紐付けます。
MockMvcで契約を確かめる
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(TasksController.class)
@Import(ApiValidationAdvice.class)
class TasksControllerTest {
@Autowired MockMvc mvc;
@Test
void 空の件名は項目別エラーを返す() throws Exception {
mvc.perform(post("/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.fields.title").value("件名を入力してください"));
}
}このテストは「エラーになった」だけでなく、利用側が頼るフィールド名とコードを固定します。メッセージを多言語化するなら、文言そのもののテストは言語を指定して分けます。
この例が扱わないエラーを混同しない
不正なJSON、日付への変換失敗、認証失敗、業務上の重複は、同じ MethodArgumentNotValidException とは限りません。またコントローラー引数に @Min などの制約を直接付けた場合、Spring MVC 6.1以降のメソッド検証では HandlerMethodValidationException が発生し得ます。そこまで共通形式にするなら、別途その例外の応答も設計してテストします。
まずは自分たちのAPIが受け取るDTOで、どの失敗を400として返すかを一つずつ固定してください。例外を何でもまとめて400にすると、サーバー側の不具合まで「入力のせい」に見えてしまいます。
Spring BootのController、入力、Serviceのつながりを通して学び直すなら『作って学ぶ Spring Boot入門』が候補です。Java、HTML/CSS、基本SQLの知識が前提で、この記事のJSON形式の出典として紹介しているわけではありません。出版社の内容紹介を確認し、必要なら脱初心者の本棚(Amazon)から探せます。Amazonのリンクにはアフィリエイトを含みます。
