團員把品項加入團購訂單時,不能只確認「品項存不存在」。
至少要確認五件事:
任何一項不成立,前端都需要知道發生了什麼錯誤。
如果每個 Controller 都自己 try-catch,久了很容易變成每個 API 都有一套錯誤處理方式。因此這次的做法是:
Service 負責判斷業務錯誤並丟出例外,Controller 不處理,最後由
GlobalExceptionHandler統一轉成 HTTP 回應。
整個流程可以拆成三件事:
GlobalExceptionHandler 怎麼把例外轉成 HTTP 回應?先把常見的錯誤定義成專案自己的例外:
| 例外 | 語意 |
|---|---|
AuthenticationException |
未通過身分驗證 |
ForbiddenException |
已登入但無權限 |
ResourceNotFoundException |
資源不存在 |
ConflictException |
與現有資料衝突,例如撞名、版本過期 |
BadRequestException |
參數不符合業務規則 |
InsufficientBalanceException |
餘額不足 |
這 6 個例外都繼承 RuntimeException,只帶一段錯誤訊息,不直接綁定 HTTP Status:
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
為什麼不直接在 Exception 裡寫 404?
因為 Exception 的責任是描述「發生了什麼錯誤」,HTTP Status 則是 API 對外的呈現方式。
例如:
ResourceNotFoundException
↓
GlobalExceptionHandler
↓
HTTP 404
這樣業務邏輯和 HTTP 回應格式就不會綁死在一起。
另外,程式中還有幾個地方直接使用 Java 內建的 IllegalStateException,表示「目前狀態不允許這個操作」,例如:
if (order.getStatus() != OrderStatus.OPEN) {
throw new IllegalStateException("訂單非開團中狀態");
}
這目前可以運作,但後面會看到它其實有一個設計上的問題。
例外一路傳到 GlobalExceptionHandler 後,接下來就是把不同的例外翻譯成不同的 HTTP Status。

目前有 11 個 @ExceptionHandler:
| 接住的例外 | 狀態碼 | detail 回什麼 |
|---|---|---|
AuthenticationException |
401 | 例外訊息 |
ForbiddenException |
403 | 例外訊息 |
ResourceNotFoundException |
404 | 例外訊息 |
ConflictException |
409 | 例外訊息 |
BadRequestException、InsufficientBalanceException、IllegalStateException |
400 | 例外訊息 |
MethodArgumentNotValidException(@Valid 失敗) |
400 | 第一個欄位錯誤的訊息 |
MissingServletRequestParameterException |
400 | 「必須輸入 」+參數名 |
MethodArgumentTypeMismatchException(例如 ?categoryId=abc) |
400 | 「參數格式錯誤: 」+參數名 |
DataIntegrityViolationException(唯一索引擋下) |
409 | 固定訊息「資料已存在或違反唯一性約束」 |
可以看到,這裡不只有自己定義的例外,也包含 Spring 和資料庫可能丟出的例外。
其中訊息處理有一個原則:
自己定義的例外,可以直接使用原本的訊息;框架或資料庫產生的錯誤,則轉成對外安全的訊息。
例如資料庫發生唯一性衝突時,原始訊息可能長這樣:
Duplicate entry '飲料-1' for key 'categories.uk_name_active'
這種訊息直接回給前端,會把表名、索引名稱等資料庫內部資訊暴露出去。
因此 API 不直接回傳原始錯誤,而是轉成:
資料已存在或違反唯一性約束
原始錯誤則只留在後端 log,方便除錯。
最後,這些 Handler 統一回傳 ProblemDetail,讓 API 的錯誤格式保持一致:
{
"type": "about:blank",
"title": "Conflict",
"status": 409,
"detail": "分類名稱已存在",
"instance": "/category/create_category"
}
前端主要需要讀取 status 和 detail,就能知道:
發生什麼類型的錯誤,以及要顯示什麼訊息。
目前比較值得改善的是 IllegalStateException。
它是 Java 內建的通用例外,GlobalExceptionHandler 現在只要看到 IllegalStateException 就當成 400 處理。
問題是 Handler 只看得到「型別」,不知道這個例外是不是我們預期的業務錯誤。
因此,像「訂單目前不是開團中狀態」這種業務錯誤,後續可以改成專案自己的例外,例如:
throw new BadRequestException("訂單非開團中狀態");
如此一來,Handler 處理的就會是我們明確定義過的錯誤,而不是一個範圍很大的 Java 通用例外。
這次的例外設計,最後可以濃縮成一句話:
Service 負責判斷「發生什麼錯誤」,
GlobalExceptionHandler負責決定「這個錯誤要怎麼對外呈現」。
這樣錯誤處理就不需要散落在每個 Controller,而是有一個統一的出口。