iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Vibe Coding

做一個團購後端,順便搞懂那些事系列 第 6

Day 6|例外設計:讓錯誤一路走到統一出口

  • 分享至 

  • xImage
  •  

團員把品項加入團購訂單時,不能只確認「品項存不存在」。

至少要確認五件事:

  1. 訂單是否存在
  2. 訂單是否還在開團中
  3. 訂單是否已經超過截止時間
  4. 品項是否存在
  5. 品項是否已經下架

任何一項不成立,前端都需要知道發生了什麼錯誤。

如果每個 Controller 都自己 try-catch,久了很容易變成每個 API 都有一套錯誤處理方式。因此這次的做法是:

Service 負責判斷業務錯誤並丟出例外,Controller 不處理,最後由 GlobalExceptionHandler 統一轉成 HTTP 回應。

整個流程可以拆成三件事:

  • 專案有哪些例外?
  • Service 丟出的例外怎麼一路傳出去?
  • 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("訂單非開團中狀態");
}

這目前可以運作,但後面會看到它其實有一個設計上的問題。


Service 發現錯誤後,誰負責回應?

例外一路傳到 GlobalExceptionHandler 後,接下來就是把不同的例外翻譯成不同的 HTTP Status。

https://ithelp.ithome.com.tw/upload/images/20260920/20168667Xt5oS8pPad.png

目前有 11 個 @ExceptionHandler

接住的例外 狀態碼 detail 回什麼
AuthenticationException 401 例外訊息
ForbiddenException 403 例外訊息
ResourceNotFoundException 404 例外訊息
ConflictException 409 例外訊息
BadRequestExceptionInsufficientBalanceExceptionIllegalStateException 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"
}

前端主要需要讀取 statusdetail,就能知道:

發生什麼類型的錯誤,以及要顯示什麼訊息。


這套設計還能怎麼改善?

目前比較值得改善的是 IllegalStateException

它是 Java 內建的通用例外,GlobalExceptionHandler 現在只要看到 IllegalStateException 就當成 400 處理。

問題是 Handler 只看得到「型別」,不知道這個例外是不是我們預期的業務錯誤。

因此,像「訂單目前不是開團中狀態」這種業務錯誤,後續可以改成專案自己的例外,例如:

throw new BadRequestException("訂單非開團中狀態");

如此一來,Handler 處理的就會是我們明確定義過的錯誤,而不是一個範圍很大的 Java 通用例外。

這次的例外設計,最後可以濃縮成一句話:

Service 負責判斷「發生什麼錯誤」,GlobalExceptionHandler 負責決定「這個錯誤要怎麼對外呈現」。

這樣錯誤處理就不需要散落在每個 Controller,而是有一個統一的出口。


上一篇
Day 5|為什麼 Controller 不能直接回傳 Entity?
下一篇
Day 7|HS256 v.s RS256:兩種簽章演算法有什麼不同?
系列文
做一個團購後端,順便搞懂那些事8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言