エラー対応は単なる例外処理ではない——信頼性の設計原則
システムが深夜にアラートを発する原因の多くは、不適切なエラー処理に起因します。NullPointerExceptionやSQLExceptionがログに乱立しても、その背後にある根本原因や影響範囲が不明瞭だと、復旧は遅れ、再発防止も困難になります。Spring Bootでは、例外を単なる「障害」ではなく、状態遷移の一部と捉え、意図的に設計することで、運用負荷を劇的に軽減できます。
エラー設計の3つの柱
- 可観測性:エラー発生時に必要な文脈(トレースID、リクエストID、パラメータ摘要)を自動付与
- 分離性:ビジネスロジックとエラー表現ロジックを完全に分離し、コントローラー層で例外をキャッチしない
- 一貫性:HTTPステータス、エラーコード、レスポンス構造を全エンドポイントで統一
Spring Bootのデフォルト挙動を見直す
Spring BootはErrorControllerとBasicErrorControllerにより、未処理例外を自動的にHTMLまたはJSON形式で返却しますが、この仕様は開発用であり、本番環境では制御不能な情報漏洩リスクがあります。以下のように、明示的なグローバルハンドラを定義することで、レスポンス内容・ステータス・ヘッダーを完全にカスタマイズ可能です:
@RestControllerAdvice
public class UnifiedErrorHandler {
private static final Logger logger = LoggerFactory.getLogger(UnifiedErrorHandler.class);
@ExceptionHandler(BusinessRuleViolationException.class)
public ResponseEntity<ErrorResponse> handleBusinessRuleViolation(
BusinessRuleViolationException ex, HttpServletRequest request) {
String traceId = MDC.get("traceId");
ErrorResponse error = ErrorResponse.builder()
.code("BUSINESS_RULE_VIOLATION")
.message(ex.getMessage())
.details(ex.getDetails())
.timestamp(Instant.now().toString())
.traceId(traceId)
.build();
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(
MethodArgumentNotValidException ex, HttpServletRequest request) {
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.toList());
ErrorResponse error = ErrorResponse.builder()
.code("VALIDATION_FAILED")
.message("リクエストデータに不備があります")
.details(Map.of("fieldErrors", errors))
.build();
return ResponseEntity.badRequest().body(error);
}
}
階層化されたカスタム例外クラス設計
例外は「何が起こったか」ではなく、「誰が責任を持ち、どう対応すべきか」を伝えるインターフェースです。以下の設計では、例外の種類ごとに責務を明確に分離しています:
// ルート例外:すべてのアプリケーション例外はこれを継承
public abstract class ApplicationFault extends RuntimeException {
private final FaultCode faultCode;
private final Map<String, Object> context;
protected ApplicationFault(FaultCode code, String message) {
super(message);
this.faultCode = code;
this.context = new HashMap<>();
}
public ApplicationFault withContext(String key, Object value) {
this.context.put(key, value);
return this;
}
// 省略:getterなど
}
// 業務ルール違反(例:在庫不足、権限不足)
public class BusinessRuleViolationException extends ApplicationFault {
public BusinessRuleViolationException(String message) {
super(FaultCode.BUSINESS_RULE_VIOLATION, message);
}
}
// 外部サービス連携失敗(タイムアウト/HTTPエラーなど)
public class ExternalServiceFailureException extends ApplicationFault {
private final String serviceName;
private final int httpStatus;
public ExternalServiceFailureException(String serviceName, int status, String message) {
super(FaultCode.EXTERNAL_SERVICE_FAILURE, message);
this.serviceName = serviceName;
this.httpStatus = status;
}
}
// 持続層エラー(DB接続/トランザクションなど)
public class PersistenceLayerException extends ApplicationFault {
public PersistenceLayerException(String message, Throwable cause) {
super(FaultCode.PERSISTENCE_ERROR, message);
initCause(cause);
}
}
標準化されたエラーレスポンス構造
フロントエンドやモバイルアプリがエラーを適切に処理するには、構造の予測可能性が不可欠です。以下のErrorResponseは、RESTful APIのエラー通信プロトコルとして機能します:
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ErrorResponse {
private String code; // システム内部コード(例: "VALIDATION_FAILED")
private String message; // ユーザー向けメッセージ(国際化対応可能)
private Map<String, Object> details; // 補足情報(フィールド名、値、推奨アクションなど)
private String timestamp; // ISO-8601形式のタイムスタンプ
private String traceId; // 分散トレーシング用ID
private String requestId; // 各リクエスト固有の識別子
}
エラーコードの列挙型設計
文字列によるエラーコードは保守性が低いため、列挙型で厳密に管理します。各コードはHTTPステータスと意味論的カテゴリを内包し、API仕様書との整合性を保証します:
public enum FaultCode {
SUCCESS(200, "正常終了"),
VALIDATION_FAILED(400, "リクエスト検証失敗"),
AUTHENTICATION_REQUIRED(401, "認証が必要です"),
ACCESS_DENIED(403, "アクセスが拒否されました"),
RESOURCE_NOT_FOUND(404, "要求されたリソースが存在しません"),
CONFLICT(409, "業務ルールに違反しています"),
EXTERNAL_SERVICE_FAILURE(503, "外部サービスが利用できません"),
PERSISTENCE_ERROR(500, "データ永続化に失敗しました"),
UNEXPECTED_ERROR(500, "予期せぬエラーが発生しました");
private final int httpStatus;
private final String description;
FaultCode(int httpStatus, String description) {
this.httpStatus = httpStatus;
this.description = description;
}
public int getHttpStatus() { return httpStatus; }
public String getDescription() { return description; }
}
ログ出力のベストプラクティス
エラー発生時のログは、単なるスタックトレースではなく、再現・分析・修正の起点となるべきです。MDC(Mapped Diagnostic Context)を活用して、トレースIDやリクエストIDを自動付与し、ログを横断的に検索可能にします:
@ExceptionHandler(ApplicationFault.class)
public ResponseEntity<ErrorResponse> handleApplicationFault(
ApplicationFault ex, HttpServletRequest request) {
String traceId = Optional.ofNullable(MDC.get("traceId"))
.orElse(UUID.randomUUID().toString());
logger.error("ApplicationFault occurred [traceId={}, path={}, method={}]",
traceId,
request.getRequestURI(),
request.getMethod(),
ex); // exを最後に渡すことで、スタックトレースを含む
return ResponseEntity
.status(ex.getFaultCode().getHttpStatus())
.body(ErrorResponse.builder()
.code(ex.getFaultCode().name())
.message(ex.getMessage())
.details(ex.getContext())
.traceId(traceId)
.timestamp(Instant.now().toString())
.build());
}