🎯 什麼情境該想到我

當你「有一堆細粒度物件,跨網路呼叫次數太多導致很慢,想用一個粗粒度介面包起來」的時候。

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

意圖:為一組細粒度物件提供一個粗粒度的遠端介面,減少跨程序/跨網路的呼叫次數。

做法要點:

  • 遠端呼叫成本高(序列化、網路延遲),細粒度介面在遠端會很慢。
  • 建立一個外觀物件,把多個細瑣操作聚合成少數幾個粗粒度方法。
  • 外觀本身不含領域邏輯,只做聚合與轉發;領域邏輯留在原本的細粒度物件。
  • 一次遠端呼叫就完成一組相關操作,往返大幅減少。

★★ 它的理由只有一個:遠端呼叫很貴,所以介面要粗

★ 細粒度的遠端介面(把本地物件的 API 直接暴露出去):
  client.GetOrder(id)      → 1 次往返
  order.GetCustomer()      → 1 次往返
  order.GetLines()         → 1 次往返
  line.GetProduct()        → ★★ N 次往返
→ ★ 一個「顯示訂單頁」變成 20 次往返 × 每次 50 ms = ★★ 1 秒

★★ 遠端外觀:一次呼叫回傳整頁需要的東西
  client.GetOrderDetail(id) → 1 次往返

★★ 這正是 GraphQL 與 BFF(Backend for Frontend)存在的理由——
它們是遠端外觀在 2026 年的樣貌。

★★ 今天的三種遠端外觀

做法說明★ 代價
① ★ BFF(每個客戶端一個外觀)Web 一個、iOS 一個 —— ★ 各自回傳剛好需要的欄位★ N 個 BFF 要維護
② ★★ GraphQL★ 客戶端自己決定要什麼欄位★★ N+1 問題(要 DataLoader)、查詢複雜度攻擊、快取困難
③ ★ 複合端點(GET /orders/{id}/detail)一個為特定畫面設計的端點★ 端點數量成長;★ 但最簡單、最好快取

★★ ③ 最被低估:它不需要新技術棧,Cache-Control 與 CDN 直接可用,
而且**「這個端點服務哪個畫面」是明確的**——★ 一個畫面改了,只改那個端點。

★ 判準:客戶端種類多且需求差異大 → ①②;★ 只有一兩種客戶端 → ③。

★★ 三條紀律(違反任一條就失去它的價值)

① ★★ 遠端外觀不可以有業務邏輯
   ★ 它只做「呼叫多個服務/查詢 → 組裝成回應」
   → ★ 業務規則屬於 [[服務層]];★★ 放進外觀 = 每個客戶端的外觀各有一份規則

② ★★ 回傳 [[資料傳輸物件]],不是領域物件
   → ★ 領域物件序列化出去 = 內部模型變成公開契約

③ ★★ 粗粒度不等於「一個萬用端點」
   ❌ POST /api { "action": "...", "params": {...} }
   → ★ 失去 HTTP 的一切(快取、狀態碼、冪等語義、可觀測性)
   → ★★ 粗粒度指的是「一次拿齊一個畫面需要的資料」,不是「一個端點做所有事」

★★ Go 的實作:並行取資料是重點

func (f *OrderFacade) Detail(ctx context.Context, id OrderID) (OrderDetail, error) {
    // ★★ 三個獨立的查詢 → 並行,不要循序
    g, ctx := errgroup.WithContext(ctx)               // ★ 任一個失敗就取消其他
    var (
        order    *Order
        customer *Customer
        shipping *Shipment
    )
    g.Go(func() (err error) { order, err = f.orders.Get(ctx, id); return })
    g.Go(func() (err error) { customer, err = f.customers.Get(ctx, custID); return })
    g.Go(func() (err error) {
        shipping, err = f.shipments.Get(ctx, id)
        if errors.Is(err, ErrNotFound) { return nil }   // ★★ 可選的部分:找不到不算失敗
        return err
    })
    if err := g.Wait(); err != nil { return OrderDetail{}, err }
    return assemble(order, customer, shipping), nil    // ★ 純函式組裝,好測
}

★★ 兩個關鍵設計:
① errgroup.WithContext → 一個失敗就取消其他,不浪費資源。
② ★★ 區分「必要」與「可選」的下游:
shipping 找不到只是「還沒出貨」,不該讓整個頁面失敗。

★ 這是遠端外觀最重要的一個設計決定:
哪些下游是必要的、哪些失敗時可以降級回傳部分資料(見 工具-服務容錯設計)。

★★ 它的固有風險:可用性相乘

★ 一個外觀依賴 5 個下游,每個 99.9%
→ ★★ 全部必要:0.999⁵ = 99.5% (一年多約 40 小時不可用)
→ ★ 其中 3 個可選:實際可用性接近 99.8%
→ ★★ 所以「哪些可選」直接決定了這個端點的 SLO

★ 配套必做的三件事:每個下游各自的超時、斷路器、以及部分失敗的回應格式
(回 200 + "shipping": null 還是回 206?★ 要明確定義並寫進 API 文件)。

★ 什麼時候不需要遠端外觀

  • ★★ 只有一個客戶端,而且它就是你自己的前端 → ★ 直接讓那個 handler 組資料,
    不需要一個叫「Facade」的額外層(見 服務層)。
  • ★ 客戶端在同一個行程內(模組化單體)→ 遠端呼叫的成本不存在。
  • ★ 資料本來就在一個服務裡 → 一個 SQL JOIN 勝過任何外觀。

★ 兄弟辨析

差別
服務層★ 本地的操作邊界(交易、授權);遠端外觀是為遠端呼叫減少往返
資料傳輸物件★ 遠端外觀回傳的型別
外觀模式★ 行程內的簡化入口(沒有「減少往返」的動機)
訊息閘道往外發訊息的封裝
散發-聚集★ 訊息世界的「問多方再彙整」

🧪 我實際套用的紀錄

  • (待填)

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

  • 若呼叫在同一程序內(無遠端邊界),不需要這層。

  • 別把領域邏輯塞進外觀,否則會變成貧血又難維護。

  • 通常與資料傳輸物件搭配,一次把資料整包傳回。

  • ★★ 遠端外觀不可以有業務邏輯。 放進去 = 每個客戶端的外觀各有一份規則。

  • ★★ 「粗粒度」不等於「一個萬用端點」。 POST /api {action, params} 失去 HTTP 的快取、狀態碼、冪等語義與可觀測性。

  • ★★ 必須區分「必要」與「可選」的下游。shipping 找不到只是還沒出貨,不該讓整頁失敗。這是這個模式最重要的設計決定。

  • ★★ 可用性相乘:5 個全必要的 99.9% 下游 = 99.5%(一年約 40 小時不可用)。「哪些可選」直接決定 SLO。

  • ★ 部分失敗的回應格式要明確定義並寫進 API 文件(200 + null 還是 206)。

  • ★ 用 errgroup.WithContext 並行取資料,一個失敗就取消其他。

  • ★ 回傳 DTO 不是領域物件,否則內部模型變成公開契約。

  • ★ 只有一個客戶端就不需要一個叫「Facade」的額外層,直接讓 handler 組資料。

  • GraphQL 的代價是 N+1(需要 DataLoader)、查詢複雜度攻擊、快取困難。 「複合端點」常常更划算。

🔗 相關工具