一個是客戶填的,一個是系統算的。混在一起就會爛。
購物車最常見的設計錯誤,是只有一個「購物車物件」。
那個物件裡面同時有 items、coupon,也有 subtotal、discount、total、gifts。它從 session 讀出來、被 controller 改、再存回 session。改久了沒有人知道裡面哪些欄位是「客戶選的」、哪些是「算出來的」,也沒有人知道那份 total 是什麼時候算的、還準不準。
我的做法是拆成兩個物件,而且它們的生命週期完全不同。
items sku => 數量
coupon 折扣碼
addons sku => 數量
points 想折抵的點數
note 訂單備註
全部都是客戶按出來的東西,一個金額欄位都沒有。
它的生命週期是「跟著 session 走」,客戶三天沒回來,它還在。所以它必須是那種放三天也不會過期的資料——SKU 跟數量放三天沒問題,total 放三天就是錯的。
操作它的方式是回傳新物件:
CartInput ──addItem / withCoupon / removeAddOn──▶ CartInput
lines CartLine[](帶 name、unitPrice,從 DB 撈出來的)
adjustments Adjustment[](折扣 / 加價)
gifts Gift[]
addOns CartLine[]
pointsEarned / pointsRedeemed
它的生命週期是「這一個 request」。要顯示購物車、要結帳,就從 CartInput 撈 DB、組一份全新的 CartState,跑完計價 pipeline,用完丟掉。永遠不存回 session、不存進任何地方。
CartInput ──(撈 DB)──▶ CartState ──pipeline──▶ CartState'
因為儲存的東西必須能放很久,計算的東西必須夠新。 這是兩個互相衝突的要求,一個物件滿足不了。
分開之後,很多問題自動消失:
CartState 每次重建。還有一個很容易被忽略的差別:同一個概念,兩邊各有一份,而且不一樣。
點數就是例子。CartInput 有 pointsToRedeem(客戶「想」折抵多少),CartState 有 pointsRedeemed(pipeline 夾制後「實際」折抵多少)。
客戶輸入 5000 點,但他只有 800 點、而且上限是訂單金額的 20%,最後實際折抵 600。這時 session 裡存的仍然是 5000——因為那就是他的意圖。等他之後又加購了幾件商品,金額上去了,同一個意圖自然就折得更多。
如果只有一個物件,你就得在客戶輸入的當下把 5000 夾成 600 存回去,然後他加購之後點數莫名其妙不會變多,還得寫一堆補救邏輯。
意圖跟結果是兩件事,就給它們兩個物件。