エラー応答を設計する ― Spring Bootの入力検証と例外ハンドリング
正常系のAPIは半日で書けたのに、テスト工程で「エラーのときのJSONが決まっていない」と指摘される。よくある流れです。フロントエンド側はmessageを読むつもりで作り、こちらは何も入れていない。存在しない注文IDを指定したら500が返り、ログには例外がそのまま出ている。
この回では、注文受付APIの入口で、入力検証の失敗と業務上の失敗をどう表現するかを決めます。「例外を握りつぶさない」という心構えの話ではなく、どのクラスがどの層で何を返すか、という配置の話です。
この記事でわかること
- Spring Boot 3.5が標準例外を
ProblemDetailへ変換する流れ -
@Validの失敗が、どこで例外になり、どう400へ変わるか -
@RestControllerAdviceでエラー応答を1か所へ集める書き方 -
ProblemDetailを使うか、独自のエラー形式を作るかの判断 - ステータスコードを400・404・409・422のどれにするかの考え方
何も書かなくても、標準例外はエラー応答になる
Spring MVCの標準例外はErrorResponseとして扱われ、Spring Boot 3.5ではProblemDetail形式の応答になる場合があります。一方、MVCの外へ抜けてServletのエラーディスパッチへ進んだ失敗は、既定では/errorに割り当てられたBasicErrorControllerが扱います。すべてのエラーが同じ一経路を通るわけではありません。
存在しないパスへJSONを要求したとき、Spring Boot 3.5.16の検証プロジェクトでは次の404応答になりました。
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "No static resource not-mapped.",
"instance": "/not-mapped"
}
このdetailはSpring MVCが生成した標準文言で、スタックトレースは含まれません。BasicErrorController側の応答でも、server.error.include-messageとserver.error.include-stacktraceは既定で内部情報を出さない側に倒れています。
# 開発中だけ、必要なら緩める。本番へ持ち込まない
server.error.include-message=on_param
server.error.include-stacktrace=never
つまり、何も設計しなくても何らかのJSONは返りますが、それが自分たちのAPI契約とは限りません。エラー応答の設計とは、標準経路と独自ハンドラーの境界を知り、呼び出し側が扱える情報へそろえる作業です。
入力検証の失敗は、業務の失敗と別物
まず、入口で弾ける入力の誤りを分けます。
public record CreateOrderRequest(
@NotBlank String productCode,
@Min(1) int quantity) {
}
@PostMapping
public ResponseEntity<OrderResponse> create(
@Valid @RequestBody CreateOrderRequest request) {
// ここへ入った時点で、productCodeは空でなくquantityは1以上
}
@Validが付いた引数は、Controllerのメソッドへ入る前に検証されます。失敗するとMethodArgumentNotValidExceptionが投げられ、Spring MVCがこれを400として扱います。この検証は「フォーマットとして受け付けられるか」だけを見ています。 商品コードが実在するか、その数量を今受け付けてよいかは、Bean Validationの担当ではありません。
public class OrderNotFoundException extends RuntimeException {
private final Long orderId;
public OrderNotFoundException(Long orderId) {
super("order not found: " + orderId);
this.orderId = orderId;
}
public Long getOrderId() {
return orderId;
}
}
public class OutOfStockException extends RuntimeException {
public OutOfStockException(String productCode) {
super("out of stock: " + productCode);
}
}
業務上の失敗は、Serviceが自分の言葉で例外を投げます。ここでHTTPステータスを決めないのが要点です。Serviceは「在庫が足りない」ことしか知らず、それを409にするか422にするかはAPIの都合だからです。同じServiceをバッチや別の入口から呼ぶ可能性を残すなら、HTTPの語彙をServiceへ持ち込まないほうが、後で剥がす手間がありません。
エラー応答を1か所へ集める
@RestControllerAdviceを付けたクラスは、Controllerで処理されなかった例外を横断的に受け取ります。
@RestControllerAdvice
public class OrderApiExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log =
LoggerFactory.getLogger(OrderApiExceptionHandler.class);
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException e) {
ProblemDetail body = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, "指定された注文は存在しません。");
body.setTitle("Order Not Found");
body.setProperty("orderId", e.getOrderId());
return body;
}
@ExceptionHandler(OutOfStockException.class)
public ProblemDetail handleOutOfStock(OutOfStockException e) {
// Spring Boot 3.5系(Spring Framework 6.2)のenum名は旧称のまま。
// HTTP上の名称は 422 Unprocessable Content
ProblemDetail body = ProblemDetail.forStatusAndDetail(
HttpStatus.UNPROCESSABLE_ENTITY,
"在庫が不足しているため受け付けられません。");
body.setTitle("Out Of Stock");
return body;
}
/** Bean Validationの失敗。標準例外はこの基底クラスが受け取る */
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
List<Map<String, String>> errors = ex.getBindingResult()
.getFieldErrors().stream()
.map(fe -> Map.of(
"field", fe.getField(),
"message", String.valueOf(fe.getDefaultMessage())))
.toList();
ProblemDetail body = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "入力内容を確認してください。");
body.setTitle("Validation Failed");
body.setProperty("errors", errors);
return ResponseEntity.badRequest().body(body);
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception e) {
log.error("想定外の例外", e);
return ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
"処理を完了できませんでした。");
}
}
最後のException.classのハンドラーが、この設計の分かれ目です。外へ返す文言と、ログへ残す情報を、ここで明確に分けています。 呼び出し側には原因を書かず、スタックトレースはログにだけ出す。これを書いておかないと、想定外の例外だけが冒頭の「原因が分からない500」に戻ります。
ProblemDetailは、Spring Framework 6で導入されたエラー表現で、RFC 9457(RFC 7807を廃止・置換した後継)のapplication/problem+jsonに対応します。type title status detail instanceという決まった項目を持ち、setProperty()で独自項目を足せます。
{
"type": "about:blank",
"title": "Order Not Found",
"status": 404,
"detail": "指定された注文は存在しません。",
"instance": "/orders/9999",
"orderId": 9999
}
Spring Boot 3.5.16でGET /orders/9999を実行し、上記のinstanceとorderIdを含む404応答を確認しました。ProblemDetailのinstanceを明示しない場合、Spring MVCが現在のリクエストパスを設定します。
ResponseEntityExceptionHandlerを継承しているのは、Bean Validationの失敗を同じ形式へ揃えるためです。この基底クラスがSpring MVCの標準例外をProblemDetailとして扱ってくれるので、あとはhandleMethodArgumentNotValidをoverrideして、どのフィールドが、なぜ弾かれたかを独自プロパティへ詰めます。既定のdetailには項目別の理由が整理された形で入らないためです。
ProblemDetailを必ず使う必要はありません
判断材料はこう置いています。新規のAPIで組織標準がないなら、ProblemDetailが第一候補です。 既存APIに後から入れる場合は、すでに動いているクライアントとの互換性が優先で、形式を変えるなら移行版かメディアタイプを分ける設計が要ります。
どちらを選んでも、固定すべき契約は同じです。エラーコード、項目別のエラー、相関ID。この3つがどの応答にも同じ位置で入っていれば、形式そのものは組織の事情に合わせて構いません。「エラー応答を1か所へ集める」ことのほうが本題です。
ステータスコードを決める
迷いやすい4つを、判断できる形にします。
| コード | 使う場面 | 注文受付での例 |
|---|---|---|
| 400 Bad Request | 構文として受け取れない。JSONが壊れている、型が違う |
quantityに文字列が来た |
| 404 Not Found | 指定された資源が存在しない | 存在しない注文IDを参照した |
| 409 Conflict | 資源の現在の状態と衝突している | 出荷済みの注文をキャンセルしようとした |
| 422 Unprocessable Content | 構文は正しいが、内容として処理できない | 在庫が足りない、受付時間外 |
422はRFC 9110でUnprocessable Contentへ改称されましたが、Spring Boot 3.5系が使うSpring Framework 6.2のenum名はHttpStatus.UNPROCESSABLE_ENTITYのままです。本文とコードで名前が食い違って見えるのは、この事情によります。
409と422の線引きは、現場でいちばん揉みます。判断軸はRFC 9110の定義に置きます。409は「対象リソースの現在の状態・版・状態遷移と競合している」こと、422は「構文も内容の種別も理解できたが、依頼された処理を実行できない」ことです。 再送すれば通るかどうかは、どちらの定義にも含まれません。競合を解消してから再送すれば成功する409もあります。
在庫不足は、実はどちらにもなり得ます。在庫を「注文リソースと競合する状態」として設計すれば409ですし、「注文内容が満たせなかった業務制約」として設計すれば422です。本シリーズでは後者を採り、在庫不足は422として扱う、とAPIの設計として宣言します。 大事なのはどちらを選ぶかではなく、選んだ基準をチームで合意して文書へ残すことです。合意しないまま各自が選ぶと、同じAPI内で基準が混ざり、後から統一するほうが高くつきます。
なお、Bean Validationの失敗を400にするか422にするかも同じ論点です。Spring MVCの既定は400なので、既定に合わせて400で通すのがいちばん摩擦が少ないと考えています。既定を変えるなら、変えた理由を書き残してください。
実務ではこうなる
@RestControllerAdviceに届かない例外があります。 これがいちばん引っかかる点です。@ControllerAdvice系はDispatcherServletの内側で動くため、その手前のServletフィルターで起きた例外は捕まりません。認証を行うフィルターの中で投げた例外が、設計したはずのJSONではなく既定のエラー応答で返ってくる、という現象はここから来ます。
着地点はその層に用意します。自前のフィルターならフィルター自身で捕捉して応答を書き、Spring SecurityならAuthenticationEntryPoint(未認証)とAccessDeniedHandler(権限不足)で形式を揃えます。本シリーズではSecurityを扱わないので詳細は公式資料へ譲りますが、「エラー形式はAdviceだけでは揃わない」ことは、設計の段階で見込んでおいてください。
例外の粒度を増やしすぎると、Advice側が肥大します。 業務例外を1つのクラスにエラーコードのフィールドを持たせて表現するか、例外クラスを分けるかは設計判断です。ハンドラーが増えて一覧性が落ちてきたら、例外側にエラーコードを持たせ、共通処理へまとめられないかを検討してください。
相関IDを応答へ入れておくと、問い合わせが一往復で済みます。 「エラーになりました」という連絡から該当ログを探すのは大変です。ProblemDetailの独自プロパティへリクエストIDを入れ、同じ値をログにも出しておけば、その値ひとつで追跡できます。
エラー応答はテストで固定します。 正常系だけテストして異常系を手作業で確認すると、後からハンドラーを1つ足したときに既存の応答が変わったことに気付けません。ステータスコードと本文の主要項目は、MockMvcで検証対象にします。テストの書き方は第8回で扱います。
検証プロジェクトでは、未割り当てパスの404、数量0のValidation失敗、存在しない注文の業務例外を実行しました。Validation失敗は400で、title、detail、instance、項目別errorsを含むことを確認しています。想定外例外はテストで意図的に発生させ、500本文に例外名・スタックトレースを含めないことと、サーバーログへ記録することを固定します。
迷ったときに見る順番
- その失敗は入力の形式か、業務の判断か
- 業務の判断なら、Serviceは自分の言葉の例外を投げているか(HTTPを知らないか)
- Adviceが例外の種類ごとに応答を組み立てているか
- 外へ返す文言とログへ残す情報が分かれているか
- 想定外の例外を受ける最後のハンドラーがあるか
次回は、その応答を組み立てる手前にあるDTOとEntityの境界、そしてSpring Data JPAを扱います。
公式リファレンス
- Spring Boot 3.5: Error Handling
- Spring Framework Reference: Error Responses
- Spring Framework Reference: @ExceptionHandler
- Spring Framework Reference: Java Bean Validation
- RFC 9457: Problem Details for HTTP APIs
参考文献・参照資料
- Somnath Musib, Spring Boot in Practice, Manning, ISBN 9781617298813:第7章「Developing RESTful Web services with Spring Boot」の例外処理・APIテストの節を参照。
- Mark Heckler, Spring Boot:立ち上げて実行する、ISBN 9798341626911(AI翻訳版):第3章のREST API作成を補助参照。原著は2021年・Spring Boot 2.4系のため、
ProblemDetailなど本記事の3.x固有の内容は公式ドキュメントで確認しています。
書籍のコード・図・訳文は転載せず、架空の注文受付サンプルとして書き直しています。

