🎯 什麼情境該想到我

當你「想把對外部系統或資源的存取封裝起來、給應用一個好用的介面」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:用一個物件封裝對外部系統或資源的存取。

做法要點:

  • 把外部 API、訊息系統、資料來源等的存取細節,包成一個對應用友善的簡單介面。
  • 應用其餘部分只呼叫這個閘道,不直接碰外部系統的複雜性。
  • 外部系統改變時,只要改閘道;測試時也能替換成假的實作。

★★ 它是 PoEAA 裡最常用、也最容易寫錯的基礎模式

// ★ 閘道 = 用「你自己的詞彙」封裝一個外部資源
type PaymentGateway interface {                 // ★ 你的詞彙
    Charge(ctx context.Context, amount Money, token CardToken) (ChargeID, error)
    Refund(ctx context.Context, id ChargeID, amount Money) error
}
// ★★ 外面是 Stripe 的 SDK、它的錯誤碼、它的 idempotency key 慣例 —— 全部藏在實作裡

★★ 判準:閘道的介面裡出現外部系統的詞彙了嗎?
出現(StripeChargeParams、http.Response、*sql.Rows)→ 封裝失敗,
你只是搬了一層。

★★ 閘道必須翻譯的四件事(不只是「包一層」)

// ① ★★ 錯誤 —— 把外部錯誤翻成你的哨兵錯誤
func (g *stripeGateway) Charge(ctx context.Context, …) (ChargeID, error) {
    res, err := g.client.Charges.New(params)
    if err != nil {
        var se *stripe.Error
        if errors.As(err, &se) {
            switch se.Code {
            case stripe.ErrorCodeCardDeclined: return "", ErrCardDeclined   // ★ 你的錯誤
            case stripe.ErrorCodeRateLimit:    return "", ErrTemporary      // ★ 可重試
            }
        }
        return "", fmt.Errorf("stripe charge: %w", err)
    }
    …
}
// ★★ 沒有這一步,呼叫端就要認識 stripe.Error ⇒ 封裝形同虛設
 
// ② ★ 型別 —— 外部的 int cents / string currency → 你的 Money
// ③ ★★ 重試與冪等 —— 外部 API 的 idempotency key 慣例藏在這裡
// ④ ★ 超時與斷路器 —— 見 [[工具-服務容錯設計]]

★★ ① 是最常被跳過的,而它決定了「呼叫端能不能在不認識 Stripe 的情況下寫對」。

★★ 閘道的介面要多大:Go 的答案是「消費方定義」

// ❌★ 一個大介面放在閘道自己的 package
package payment
type Gateway interface { Charge; Refund; Capture; Void; ListDisputes; … }  // ★ 十個方法
 
// ✅★★ 介面定義在使用它的一側,只放用到的方法
package checkout
type charger interface {          // ★ 未匯出、一個方法
    Charge(context.Context, Money, CardToken) (ChargeID, error)
}

★ Go 的隱式滿足讓這件事免費:payment.StripeGateway 自動滿足 checkout.charger,
而 payment package 完全不知道它存在(見 工具-針對介面編程、分離介面)。

★★ 測試:閘道存在的最大理由

// ★ 有了閘道,測試 checkout 只需要一個十行的 fake
type fakeCharger struct{ err error; id ChargeID }
func (f fakeCharger) Charge(context.Context, Money, CardToken) (ChargeID, error) {
    return f.id, f.err
}
// ★★ 可以輕鬆測:卡片被拒、限流、超時、部分成功 —— 這些用真的 Stripe 很難觸發

★★ 但 fake 有一個致命限制:它只驗證「你假設的行為」。
★ 必須配兩種測試:

① ★ 契約測試:★★ 對真實的沙箱環境跑一組測試,驗證你的假設仍然成立
   → ★ 外部 API 改版時,這是唯一會告訴你的東西
② ★ 錄製/重播(VCR 式):錄一次真實回應,之後重播
   → ★ 比 fake 真實,比沙箱穩定;★★ 但錄的內容會過時

→ ★ 見 服務樁(它就是閘道的測試替身)。

★★ 什麼時候不需要閘道

① ★★ 標準庫或極穩定的依賴
   ★ 包一層 os.ReadFile 或 time.Now 通常是過度工程
   ⚠️ 但 time.Now 例外:★★ 測試需要控制時間 → 注入一個 func() time.Time 很值得

② ★★ 只有一個呼叫點,而且不需要測試替身
   → ★ 那是 [[冗員元素]]

③ ★ 外部 API 的語義就是你的語義
   ★ 一個內部服務的 gRPC client,而你的領域詞彙就是它的詞彙
   → ★ 包一層只是改名

★★ 判準:「你想在測試裡替換它」或「你想隔離它的變化」嗎?
兩者都不是 → 不要包。

★ 兄弟辨析

差別
對應器★ 兩邊互不認識的轉換層;閘道是「我認識外面,外面不認識我」
配接器模式★ 把 A 的介面轉成 B 的介面(既有介面對既有介面)
防腐層★ DDD 的說法,範圍更大(★ 包含模型轉換,不只 API 封裝)
服務樁★ 閘道的測試替身
表格資料閘道★ 閘道用在資料庫上的特化
訊息閘道閘道用在訊息系統上的特化

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 只是薄薄一層轉接,別在裡面塞業務邏輯。

  • 外部系統本就簡單好用時,多包一層反而是多餘。

  • 介面應照「應用需要什麼」來設計,而非照外部系統長什麼樣。

  • ★★ 判準:閘道的介面裡出現外部詞彙了嗎? 出現(stripe.Error、http.Response、*sql.Rows)→ 封裝失敗,你只是搬了一層。

  • ★★ 必須翻譯錯誤(外部錯誤碼 → 你的哨兵錯誤),否則呼叫端還是要認識外部函式庫。

  • ★★ fake 只驗證「你假設的行為」。 必須配契約測試(對沙箱跑)或錄製重播,否則外部 API 改版時沒有東西會告訴你。

  • ★ 介面定義在消費方、未匯出、只放用到的方法。 Go 的隱式滿足讓這件事免費。

  • ★ 標準庫與極穩定的依賴不需要閘道(包 os.ReadFile 是過度工程)——但 time.Now 例外,注入 func() time.Time 很值得。

  • ★ 只有一個呼叫點且不需要測試替身 → 不要包,那是 冗員元素。

  • ★ 外部 API 的語義就是你的語義時,包一層只是改名。

  • 超時、重試、冪等 key、斷路器都屬於閘道內部,不要讓呼叫端處理。

🔗 相關工具