先從一個會爆炸的設計講起
做電商訂單,最直覺的設計:一個 status 欄位走天下。
一開始很順:待付款 → 已付款 → 出貨中 → 完成。然後需求來了——「ATM 轉帳要有取號但未繳的狀態」,加;「部分退款要看得出來」,加;「包裹分兩箱寄、一箱到了一箱沒到」,加……半年後你的 status 有二十幾個值,而且開始出現 PAID_BUT_PARTIAL_REFUNDED_AND_ONE_PACKAGE_RETURNED 這種怪物——因為付款、物流、退貨是三件獨立演進的事,你卻逼它們共用一個欄位。
三件事各有 6~8 個階段,塞一個欄位理論上要 6×7×8 個組合值。這就是爆炸的根源:不是狀態太多,是把好幾台狀態機焊成了一台。
決策一:怎麼決定拆幾台?——「節奏不同就拆」
我掃了自己的電商後端,它把「一張訂單」拆成五台狀態機,各管各的:
| 狀態機 | 管什麼 | 誰在驅動它 |
|---|---|---|
| Order(6 態) | 訂單主流程 | 業務事件(付款成功、出貨、收貨) |
| Payment(8 態) | 這筆錢的狀態 | 金流商的付款結果通知 |
| Refund(4 態) | 某一次退款的進度 | 後台審核 + 金流商退款 API |
| DeliveryGroup(7 態) | 某一個包裹到哪了 | 物流商的貨態回報 |
| ReturnOrder(7 態) | 某一次退貨的進度 | 客服審核 + 物流 + 驗收 |
拆分的判斷標準就藏在第三欄:驅動者不同、節奏不同,就該是不同的機。付款狀態跟著金流商的通知跳、物流狀態跟著實體世界的貨車跳——兩者的節奏毫無關係,焊在一起只會互相干擾。
另一個訊號是數量關係:一張訂單可以有多次退款、多個包裹。「一對多」出現的瞬間,那個「多」的東西就必須有自己的狀態機(一個欄位存不了三個包裹各自的進度)。
代價也要講:拆五台,換來「查一張訂單的完整狀態要 join 五張表」。單體系統裡這是一句 SQL 的事,划算;但要記得這筆帳,之後拆微服務時它會變貴(那是另一篇的主題)。
決策二:轉移規則放哪?——寫成一張表,放在 service 層
狀態機的靈魂不是「有哪些狀態」,是「只能怎麼走」。我系統裡的實作是一張明確的轉移表:
ORDER_VALID_TRANSITIONS = {
"PENDING": ["PAID", "CANCELLED"],
"PAYMENT_FAILED": ["PENDING", "CANCELLED"], # 失敗可重試或放棄
"PAID": ["SHIPPING", "CANCELLED"],
"SHIPPING": ["COMPLETED"], # 配送中不可取消,只能走退貨
"COMPLETED": [], # 終態
"CANCELLED": [], # 終態
}每次改狀態前過一個守衛函式查這張表,非法轉移直接擋下(並回傳「目前狀態只能轉成 X、Y」的訊息)。三個實務理由:
- 規則看得見。新人不用讀遍整個專案猜「取消到底哪些階段可以按」,一張表講完。
- 改狀態只有一個門。所有轉移都走同一個
update_status(),順便把「誰、什麼時候、為什麼」寫進稽核紀錄——出事的時候這就是你的行車紀錄器。 - 放 service 層而不是 model 層,因為轉移常伴隨副作用(取消訂單要回補庫存、退還點數)——這些跨模組的動作不該塞在資料模型裡。
決策三:終態怎麼定?——「不可逆」是個哲學選擇
上面那張表有個值得停下來看的設計:COMPLETED 跟 CANCELLED 是終態,進去就出不來。那退貨呢?訂單完成後退貨很常見啊?
我系統的答案:退貨不改訂單狀態。訂單永遠停在 COMPLETED,「退過貨」這件事表達在另外兩台機上——ReturnOrder 走它的七態、Payment 轉成 REFUNDED。要知道「這張單退到什麼程度」,join 那兩台機去問。
這是兩派設計的選邊:
- A 派(我系統這種):主狀態機保持單純線性,例外流程走「側路機」。好處是主流程永遠好懂;代價是「完整狀態」要跨機拼裝。
- B 派:把 REFUNDED 加進訂單狀態機。好處是一眼看到;代價是主狀態機開始長出岔路,然後是岔路的岔路(部分退款算哪一態?退貨中算哪一態?)——爆炸又回來了。
沒有標準答案,但有個判斷:例外流程自己會不會繼續長? 會長的(退貨有審核、寄回、驗收、退款四個階段)就給它自己的機;不會長的(例如一次性的「已歸檔」)直接加態沒關係。
決策四:什麼該當狀態、什麼該當欄位?
我系統裡有個好例子:訂單完成的判定不是靠狀態,是靠兩個事實欄位——received_at(何時收到貨)和 receive_source(誰說的:物流回報/使用者按確認/超時自動)。
判斷準則:狀態是「流程走到哪」,欄位是「發生過什麼事實」。「收到貨」是一個事實(有時間、有來源),它會觸發狀態轉移(SHIPPING→COMPLETED),但它本身不是狀態。把事實塞進狀態機(RECEIVED_BY_LOGISTICS、RECEIVED_BY_USER……)就是在用狀態機當 log 用——爆炸的另一條路。
檢查清單(設計狀態機時過一遍)
- 這裡面有幾群「驅動者/節奏不同」的東西?——每群一台機
- 有沒有一對多?——「多」的那邊自己一台機
- 轉移表寫出來了嗎?改狀態有沒有唯一入口+稽核?
- 哪些是終態?例外流程(退貨/爭議)走側路還是加態?
- 每個候選狀態問一次:這是「流程階段」還是「事實」?事實改用欄位
- 最後——每個定義的態真的有 code 會寫入嗎?(沒有的話你定義的是願望,見〈dead state〉那篇)
相關
- 定義了沒接線的狀態會怎麼騙人 → dead-state 治理
- 這五台機拆到微服務會發生什麼 → 單體 vs 微服務的變化差異
- 狀態轉移的併發保護(兩個請求同時改狀態)→ 22b 亂序與鎖