2026年10月5日 星期一

為什麼 Laravel 專案還需要寫設計模式?


從一個「高併發點數系統」的真實併發問題談起

很多人在剛接觸 Laravel 時常會有一個疑問:

「Laravel 已經有了強大的 Service Container、Event/Listener、Queue、Middleware,幹嘛還要自己手寫 Strategy、State 這類設計模式?是不是過度設計(Over-engineering)?」

這個問題我以前也問過。

直到我真正帶領團隊做了一套多租戶(Multi-tenant)、高併發,且必須保證點數永遠不能為負數的 Loyalty & Point API 之後,才深刻體會到:

Laravel 是優秀的工具箱,但它不會自動幫你解決「業務複雜度」與「正確性」的問題。

這篇文章想分享我們在這個專案中,為什麼最終選擇導入 Strategy Pattern 與 State Pattern,以及它們如何幫助我們處理高併發下的業務邊界。

一、 專案背景:不是單純的 CRUD,而是「正確性優先」

這套系統的核心不是「會員管理」或「優惠券 CRUD」,而是一個高風險的金融級記帳系統:

  • 多租戶隔離(Shared Database, Row-level isolation)

  • 高併發交易(網站、APP、POS、第三方電商同時打過來)

  • 點數永遠不能為負(PointAccount.balance $\ge$ 0)

  • PointLot FIFO 消耗機制(優先扣除快到期的點數)

  • 資料庫優先的冪等性(Idempotency)

  • Transactional Outbox(確保事件 100% 可靠投遞)

簡單來說,當同一個會員的點數帳戶,同時在不同通路發生交易時,系統必須硬性保證以下公式成立:

$$\text{PointAccount.balance} \ge 0$$
$$\text{PointLot.remaining\_points} \ge 0$$
$$\text{PointAccount.balance} == \sum(\text{PointLot.remaining\_points})$$
這些是系統的硬性不變量(Domain Invariants),屬於絕對不能妥協的底線。

二、 Laravel 幫我們做了什麼?又沒做什麼?

Laravel 框架幫我們解決了大量的「開發效率」問題:

  • Dependency Injection:讓 Service 非常容易進行測試。

  • DB::transaction() + lockForUpdate():優雅地處理資料庫悲觀鎖。

  • Event + Queue:方便解耦異步任務與 Outbox 機制。

但框架沒有幫我們解決的是:

  1. 當「點數計算」有多種業務規則時,程式碼該怎麼組織?

  2. 當優惠券存在明確狀態轉換時,狀態規則該放在哪裡才不會污染交易?

  3. 當業務規則持續增加時,核心交易流程要如何維持「開閉原則(OCP)」?

這些是架構與職責劃分的問題,框架不會替你做決定。

三、 為什麼我們用了 Strategy Pattern?

點數的賺取(Earn)規則非常繁雜:

  • 一般消費(依消費金額 $\times$ 會員等級倍率)

  • 限時活動加碼(滿額贈、指定品項加倍)

  • 推薦好友獎勵(固定點數或比例抽成)

  • VIP 專屬權益

❌ 傳統寫法:萬能 Service 與 if-else 地獄

如果把邏輯全塞在 PointService 裡,很快就會變成這樣:

PHP
class PointService
{
    public function earnPoints(PointAccount $account, string $type, array $payload)
    {
        // 核心交易 Service 開始被無窮無盡的業務邏輯污染...
        if ($type === 'purchase') {
            $rate = $account->user->isVip() ? 0.02 : 0.01;
            $points = floor($payload['amount'] * $rate);
        } elseif ($type === 'campaign') {
            $points = $payload['amount'] >= 1000 ? 100 : 0;
        } elseif ($type === 'referral') {
            $points = 50;
        } // 未來還有 10 種規則...

        // 開始做 DB 記帳與 Lock...
    }
}
這種寫法的致命痛點:

  1. 違反開閉原則(OCP):每新增一種行銷活動,就要改動最危險的核心交易 Service。

  2. 職責模糊:「怎麼算點數」與「怎麼安全記帳」混在一起。

  3. 單元測試極度難寫:測試記帳邏輯時,還得傳入各種繁複的行銷參數。

✅ 解決方案:Strategy Pattern 職責分離

我們將架構切分為兩層:

  • PointEarnStrategy:只負責「算多少點數」(純計算、無 Side Effect)。

  • PointService:只負責「怎麼安全地進行 DB 記帳、Lock、寫入 PointLot 與 Outbox」。

Plaintext
       ┌──────────────────────────┐
       │   PointEarnStrategy      │  ---> 負責「算多少點」(純計算)
       └─────────────┬────────────┘
                     │ Returns calculated points
                     ▼
       ┌──────────────────────────┐
       │      PointService        │  ---> 負責「安全記帳」(DB Trans / Lock / Outbox)
       └──────────────────────────┘

1. 定義 Strategy 介面與實作

PHP
interface PointEarnStrategy
{
    public function calculate(PointAccount $account, array $payload): int;
}

class PurchaseEarnStrategy implements PointEarnStrategy
{
    public function calculate(PointAccount $account, array $payload): int
    {
        $multiplier = $account->user->isVip() ? 2 : 1;
        return (int) floor(($payload['amount'] / 100) * $multiplier);
    }
}

2. Factory 與 Service Container 結合

PHP
class PointEarnStrategyFactory
{
    public static function make(string $type): PointEarnStrategy
    {
        return match ($type) {
            'purchase' => app(PurchaseEarnStrategy::class),
            'campaign' => app(CampaignEarnStrategy::class),
            'referral' => app(ReferralEarnStrategy::class),
            default => throw new InvalidArgumentException("Unsupported earn type: {$type}"),
        };
    }
}

3. 簡化後的 PointService 核心交易流程

PHP
class PointService
{
    public function earn(PointAccount $account, string $type, array $payload): PointTransaction
    {
        // 1. 交給 Strategy 算點數
        $strategy = PointEarnStrategyFactory::make($type);
        $points = $strategy->calculate($account, $payload);

        if ($points <= 0) {
            throw new InvalidTransactionException("Earn points must be greater than zero.");
        }

        // 2. PointService 專心搞定 DB 事務、悲觀鎖與 Consistency
        return DB::transaction(function () use ($account, $points, $type, $payload) {
            // 鎖定帳戶列,避免 Lost Update
            $account = PointAccount::where('id', $account->id)->lockForUpdate()->first();

            // 更新總餘額
            $account->increment('balance', $points);

            // 建立 PointLot (批次點數,用於 FIFO 扣點與過期管理)
            $lot = $account->lots()->create([
                'initial_points' => $points,
                'remaining_points' => $points,
                'expires_at' => now()->addYear(),
            ]);

            // 寫入 Outbox Event(同一個 DB Transaction 內,確保不丟包)
            OutboxMessage::create([
                'event_type' => 'PointsEarned',
                'payload' => json_encode(['account_id' => $account->id, 'points' => $points]),
            ]);

            return $account->transactions()->create([
                'type' => $type,
                'amount' => $points,
                'balance_after' => $account->balance,
            ]);
        });
    }
}
這樣改進後,優勢非常明顯:

未來行銷團隊要加一種「VIP 生日加碼規則」,我們只需要新增一個 BirthdayEarnStrategy,核心交易與 DB 鎖的程式碼一行都不用動!

四、 為什麼優惠券要用 State Pattern?

點數系統往往緊密連結「優惠券與折抵核銷」。優惠券有非常嚴格且明確的狀態轉移(State Transitions):

Plaintext
[ Available ] ───( Redeem )───> [ Used ]
      │
      ├───( Expire )──────────> [ Expired ]
      │
      └───( Cancel )──────────> [ Cancelled ]
若直接在 Service 裡面用字符串比較:

PHP
if ($coupon->status === 'available') {
    // 執行核銷
} else {
    throw new Exception("Coupon cannot be redeemed.");
}
當未來遇到「核銷後允許退款回滾 (used $\rightarrow$ available)」、「部分凍結 (available $\rightarrow$ frozen)」等複雜情境時,狀態判斷邏輯將會迅速散落在各個 Controller 與 Service 中。

使用 State Pattern 的核心價值在於:將「狀態轉移合法性」封裝在 State 物件內部。

每個狀態物件自己決定是否允許特定動作(例如:UsedState 呼叫 redeem() 時直接拋出 InvalidStateTransitionException),確保 Service 永遠呼叫安全的狀態變更。

五、 設計模式不是為了「看起來高級」

在專案架構文件裡,有一句話我們團隊一直謹記在心:

Pattern 是 Problem Driven,而不是 Pattern Driven。

如果目前 Domain 沒有足夠的複雜度,就不要為了展示 Design Pattern 而增加抽象層。

我們並沒有為了套模式而套模式:

  • 例如 Chain of Responsibility(責任鏈模式),我們事前評估過,但目前點數資格審查(Eligibility)還沒有強烈動態組合的需求,因此選擇不採用。

  • 真正有價值的模式,是那些能直接解決當前維護痛點與擴充瓶頸的模式。

六、 真正讓系統可靠的,永遠是「正確性邊界」

設計模式解決的是「程式碼組織與維護性」,但高併發系統要能穩定運作,底層的正確性防線(Correctness Guardrails)才是關鍵:

  1. Redis Lock 只是效能優化層,不是最終正確性來源

    Redis Lock 能擋掉 95% 的重複請求,但千萬不能把它當作唯一的鎖。

  2. 資料庫 lockForUpdate() + Check Constraint 才是最後防線

    在 MySQL Migration 中,針對 balance 與 remaining_points 設定 UNSIGNED 或 CHECK (balance >= 0),搭配悲觀鎖防止競態條件(Race Condition)。

  3. 冪等性(Idempotency)靠 DB Unique Index 保證

    交易單號 (Transaction No) 掛上資料庫唯一索引,避免併發時雙倍扣款。

  4. Transactional Outbox 確保最終一致性

    領域事件(Events)必須與交易資料寫在同一個 DB Transaction(寫入 outbox_messages 表),再由獨立的 Worker 異步推送到 Message Queue,避免「DB Rollback 了,但 Event 已經送出去」的尷尬狀況。

七、 結語

Laravel 框架極度優秀,但它解決的是「開發效率」與「基礎建設」的問題。

當你的專案成長到一定規模,開始面臨:

  • 多變且頻繁追加的計算規則

  • 嚴格的狀態機轉換

  • 高併發下的一致性與防腐要求

  • 多租戶隔離下的業務邊界

這些問題框架本身不會自動幫你解決。這時候,設計模式就不是「多餘的抽象」,而是讓系統複雜度可控的最佳工具。

最後想留給大家一個思考題:

你現在維護的 Laravel Service 裡,是不是也開始出現越來越多的 if/else 或 switch?

那些判斷,是「暫時的權宜之計」,還是「未來會持續膨脹的業務複雜度」?

如果是後者,也許現在就是你把職責抽離、重構架構的好時機了。


當我們在使用 AI 輔助寫程式(例如 Cursor, Claude, Trae, Copilot, Continue)時,「良好的設計模式(Design Patterns)」其實是讓 AI 發揮 200% 戰力最關鍵的催化劑。

許多人以為「有了 AI,程式碼隨便寫就好,反正 AI 寫很快」;但實戰中正好相反:程式碼架構越爛,AI 越容易瞎編(Hallucination)、改 A 壞 B,最後維護成本高到崩潰。

以下從工程實戰的角度,拆解導入設計模式對「AI Code Generation / Refactoring」帶來的 6 大關鍵幫助:

1. 給 AI 建立極為明確的「上下文邊界(Context Boundaries)」

AI 的 Context Window(上下文視窗)是有限的,而且處理長檔案時注意力會衰減。

  • 沒有設計模式(單體萬能 Service / Controller):
    一個 1,500 行的 OrderService 裡面有計價、庫存、金流、發票、物流。你請 AI 改一個折扣邏輯,AI 必須讀取這 1,500 行,很容易因為讀到無關的金流 code 而產生幻覺,或是改動時不小心動到不該動的變數。

  • 有了設計模式(例如 Strategy / Pipeline):
    每個檔案只有 50~100 行,職責極度單一。你只需要把 PurchaseEarnStrategy.php 丟給 AI,AI 在完全乾淨、沒有雜訊的環境下思考,產出的程式碼準確率幾乎是 100%。

2. 精確的「Prompt 詠唱關鍵字」(共通語言)

設計模式是全球工程師(包含訓練 AI 的海量資料)的共通語言。

當你不用設計模式時,你必須用落落長的自然語言跟 AI 解釋:

「當使用者類型是 VIP 時,你要幫我做 A 計算,如果是常客做 B 計算,然後以後我可能還會加 C 計算,你要幫我寫得彈性一點,可以用 switch 還是什麼處理...」

當你有設計模式時,你的 Prompt 只需要一句話:

「請幫我用 Strategy Pattern 實作一個 VipEarnStrategy,介面參考 PointEarnStrategy。」

AI 的 LLM 底層對 Strategy、Factory、Decorator、State 等名詞有極深層的知識圖譜。你只要給出 pattern 名詞,AI 就知道要產出介面 (Interface)、實作類別 (Implementation) 以及工廠 (Factory)。

3. 讓 AI 能生成「100% 乾淨的單元測試(Unit Test)」

要 AI 幫寫測試最痛苦的地方,就是當原本的 Code 耦合度極高(滿滿的 DB:: 靜態呼叫、外部 API)。AI 會寫出一堆複雜又容易 Fail 的 Mock(例如要 Mock 整個 DB Transaction、Mock Redis...)。

  • 導入 Strategy / Policy 模式後:
    策略類別只做「純計算(Pure Function)」,沒有 Side Effects。

  • 對 AI 的幫助:
    你對 AI 說:「幫 PurchaseEarnStrategyTest 寫單元測試,涵蓋各種邊界條件。」AI 可以幾秒內產出完整覆蓋率、完全不需要 Mock DB 的高品質測試。

4. 大幅降低 AI 程式碼重構(Refactoring)的「副作用風險」

AI 最常被吐槽的地方就是:「叫它改一個小 Bug,結果它改了別的地方導致系統壞掉。」

設計模式的核心原則之一是「開閉原則(Open-Closed Principle, OCP)」——對擴充開放,對修改關閉。

  • 以追加「新點數規則」為例:

    • 傳統寫法: AI 必須去修改 PointService.php 裡面的 if-else。AI 很可能會漏看某個 else 或是影響到別的邏輯。

    • Strategy Pattern: 新增規則只需新建一個檔案(例如 NewCampaignEarnStrategy.php),並且在 Factory 加一行 match。AI 完全「碰不到」舊的交易與鎖定邏輯,產生 Bug 的機率降為 0。

5. 提供 AI 穩定的「模板(Boilerplate)」進行 Pattern 複製

AI 最擅長的事情是 「Pattern Recognition and Replication」(模式識別與複製)。

一旦你在專案中建立好第一個標準的設計模式範例(例如第一個 State 或第一個 Strategy):

  • 當你請 AI 寫第二個、第三個類似功能時,AI 會主動模仿專案現有的程式碼結構、命名習慣與錯誤處理方式。

  • 這時 AI 寫出來的程式碼會非常像「你們團隊自己寫的」,不再是隨機生成的網頁範例 Code。

6. AI 輔助建立「自定義 Code Review 指令」

當專案充滿設計模式與架構原則時,你可以直接在 AI 工具(如 Cursor 的 .cursorrules 或 Claude 的 System Prompt)寫下審查規則:

Markdown
# AI Code Review 規則
1. 嚴禁在 Service 裡面撰寫超過 2 個分支的 if-else 業務邏輯,請提示重構為 Strategy Pattern。
2. 狀態變更必須透過 State Pattern 物件,禁止直接寫入 status 欄位。
3. 核心交易 Service 必須維持「開閉原則」,絕不允許傳入無關的業務計算邏輯。
這樣 AI 在幫你寫 Code 或 Review Pull Request 時,就會變成你的架構守門員,自動退回不符合設計模式的程式碼。

💡 總結:AI 時代的程式設計新哲學

以前寫 Design Pattern: 為了人類工程師好維護、好讀。
現在寫 Design Pattern: 為了幫 AI 劃定好「格子」,讓 AI 在格子裡面發揮極限,且絕對不會越界破壞系統。

「人類決定架構與設計模式,AI 負責填滿裡面的細節與寫測試」,這才是 AI 時代最有效率且最穩健的開發模式!

為什麼 Laravel 專案還需要寫設計模式?

從一個「高併發點數系統」的真實併發問題談起 很多人在剛接觸 Laravel 時常會有一個疑問: 「Laravel 已經有了強大的 Service Container、Event/Listener、Queue、Middleware,幹嘛還要自己手寫 Strategy、State 這...