はじめに
APIのエラーレスポンスを設計するとき、HTTPステータスコードとmessageだけ返せば十分だと思っていませんか。
シンプルな設計に見えますが、フロントエンドの実装が増えるにつれて「このエラーはどう処理すればいいか」という問題が表面化します。
本記事ではmessageだけのエラーレスポンスが抱える問題と、codeフィールドを追加することで何が解決するかを解説します。
messageだけ返す設計の問題
よくある設計です。
{
"status": 409,
"message": "在庫不足: 商品[シャツA] 在庫=3, 注文数=5"
}
フロントエンドがこのエラーを受け取ったとき、「在庫不足の場合は特定のUIを表示したい」という要件があると困ります。messageの文字列を解析して判断するしかないからです。
// ❌ message の文字列に依存した分岐
if (error.message.includes('在庫不足')) {
showStockErrorUI();
}
この実装には2つのリスクがあります。
1つ目は、サーバ側でメッセージの文言を変更したとたんにフロントの分岐が壊れることです。
2つ目は、多言語対応でメッセージが日本語・英語で切り替わる場合、文字列比較が完全に機能しなくなることです。
codeフィールドを追加する
エラーの種別を表すcodeフィールドを追加します。
{
"code": "INSUFFICIENT_STOCK",
"status": 409,
"message": "在庫不足: 商品[シャツA] 在庫=3, 注文数=5"
}
codeは変更しない安定した識別子です。フロントエンドはcodeを根拠に分岐します。
// ✅ code を根拠にした分岐
if (error.code === 'INSUFFICIENT_STOCK') {
showStockErrorUI();
}
messageは人間向けの説明文として扱います。多言語化や文言変更が自由にできます。codeさえ変えなければフロントの実装は壊れません。
detailsフィールドの設計
バリデーションエラーのように、フィールドごとにエラー内容を伝えたい場合はdetailsを追加します。
{
"code": "VALIDATION_ERROR",
"status": 400,
"message": "バリデーションエラー",
"details": {
"name": "商品名は必須です",
"price": "価格は0より大きい必要があります"
}
}
detailsはバリデーションエラー専用フィールドです。
それ以外のエラーではdetailsフィールドごとJSONから省略します。nullを返すと「このフィールドは常に存在する」という誤解を与えるため、@JsonInclude(NON_NULL)で存在ごと消します。
// exception/ErrorResponse.java
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ErrorResponse(
String code,
int status,
String message,
Map<String, String> details
) {
public ErrorResponse(String code, int status, String message) {
this(code, status, message, null);
}
}
エラーコード一覧を管理する
codeの値はプロジェクト内で一覧管理します。命名はSCREAMING_SNAKE_CASEで統一します。
code |
HTTPステータス | 発生ケース |
|---|---|---|
VALIDATION_ERROR |
400 | リクエストボディのバリデーション失敗 |
BAD_REQUEST |
400 | パスパラメータの型不正など |
UNAUTHORIZED |
401 | 未認証 |
FORBIDDEN |
403 | 権限不足 |
NOT_FOUND |
404 | リソースが存在しない |
CONFLICT |
409 | 楽観ロック競合 |
INSUFFICIENT_STOCK |
409 | 在庫不足 |
TOO_MANY_REQUESTS |
429 | レートリミット超過 |
INTERNAL_SERVER_ERROR |
500 | 予期しないサーバエラー |
GlobalExceptionHandlerでの実装
Spring Bootでは@RestControllerAdviceを使って例外を一元処理します。
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final MessageHelper messageHelper;
// ビジネス例外を一本で処理する
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException ex) {
log.warn("{}: {}", ex.getClass().getSimpleName(), ex.getMessage());
return buildError(ex.getStatus(), ex.getCode(), ex.getMessage());
}
// バリデーションエラー
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> details = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
FieldError::getDefaultMessage,
(existing, duplicate) -> existing));
return buildError(HttpStatus.BAD_REQUEST, "VALIDATION_ERROR",
messageHelper.get("error.validation"), details);
}
// 予期しない例外(詳細は隠してログに残す)
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
log.error("Unexpected exception occurred", ex);
return buildError(HttpStatus.INTERNAL_SERVER_ERROR,
"INTERNAL_SERVER_ERROR", messageHelper.get("error.system"));
}
private ResponseEntity<ErrorResponse> buildError(
HttpStatus status, String code, String message) {
return buildError(status, code, message, null);
}
private ResponseEntity<ErrorResponse> buildError(
HttpStatus status, String code, String message,
Map<String, String> details) {
return ResponseEntity.status(status)
.body(new ErrorResponse(code, status.value(), message, details));
}
}
Spring Securityの401/403は別途設定が必要
Spring Securityの認証・認可エラーはFilterレベルで処理されるため、@RestControllerAdviceには届きません。
SecurityConfigのexceptionHandling()で直接レスポンスを書く必要があります。このときErrorResponseと同じJSON構造を維持することが重要です。
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, e) -> {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"""
{"code":"UNAUTHORIZED","status":401,"message":"認証が必要です"}
""");
})
.accessDeniedHandler((request, response, e) -> {
response.setStatus(403);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"""
{"code":"FORBIDDEN","status":403,"message":"権限がありません"}
""");
})
)
まとめ
-
messageの文字列比較でエラー分岐するのは文言変更・多言語化で壊れる -
codeフィールドが安定した機械判別用の識別子として機能する -
detailsはバリデーションエラー専用。それ以外では@JsonInclude(NON_NULL)でフィールドごと省略する -
timestampはレスポンスに含めない - Spring Securityの401/403は
SecurityConfigでErrorResponseと同じ構造で返す