規則寫在建構子,就不可能有人繞過它。
購物車有一些「永遠不該發生」的狀態。例如:
這些規則通常會被寫在 controller:
// 常見寫法
if (empty($cart['items'])) {
$cart['addons'] = [];
}
問題是,購物車有幾個 action?加入商品、修改數量、刪除商品、套用折扣碼、取消折扣碼、加購、改加購數量、刪加購、設定點數、填備註⋯⋯十個以上。
只要有一個 action 忘了寫這段檢查,你的 session 就髒了。而且一定會忘,因為三個月後新增的那個 action 是別人寫的。
CartInput 的建構子是 private 的,所有變更方法都必須經過它:
private function __construct(array $items, ?string $coupon, array $addOns, int $pointsToRedeem = 0, string $note = '')
{
// invariant:沒有正常商品就不能有加購品、也不能折抵點數或留備註
if ($items === []) {
$addOns = [];
$pointsToRedeem = 0;
$note = '';
}
$this->items = $items;
$this->coupon = $coupon;
$this->pointsToRedeem = max(0, $pointsToRedeem);
$this->note = mb_substr(trim($note), 0, self::MAX_NOTE_LENGTH);
}
而每一個變更方法,都是「改一份陣列、丟進建構子、回傳新物件」:
public function removeItem(string $sku): self
{
$items = $this->items;
unset($items[$sku]);
return new self($items, $this->coupon, $this->addOns, $this->pointsToRedeem, $this->note);
}
所以不管客戶是怎麼走到「購物車沒有正常商品」這個狀態的——他可能是一件一件刪、可能是把數量改成 0、可能是商品下架被系統移除——加價購一定會被清掉,因為每一條路都會經過建構子。
這就是 immutable 物件的實際好處:它不只是「比較安全」這種空話,而是它強迫每一次變更都重新走一次驗證。可變物件做不到這件事,因為你可以直接 $cart->items = []。
我會放進建構子的東西分三類:
跨欄位的一致性。 沒有商品就沒有加購。這是最重要的一類,因為它是「欄位之間的關係」,散在各處一定守不住。
上下限夾制。 數量最多 99、備註最多 1000 字、點數不能是負的。
正規化。 備註 trim、數量 0 就直接 unset 而不是存一個 0。讓同一個語意只有一種表示法,後面的程式碼就不用到處判斷 if ($qty === 0)。
Controller 還是要做表單驗證——那是在擋「客戶送了什麼鬼東西進來」。
建構子擋的是另一件事:擋物件本身不合法。 表單驗證通過之後,資料交給 CartInput,由它保證最後存回 session 的那份,一定是一個自洽的購物車。
分工是這樣:
Request ──驗證──▶ 合法的輸入
合法的輸入 ──建構子──▶ 合法的購物車
兩層都要,但第二層才是真正保護你 session 的那層。因為 controller 會越寫越多,建構子只有一個。