🎯 什麼情境該想到我

當你「想改的邏輯跟一堆礙事的依賴(例如 UI 相關的函式與類別)攪在同一個類別裡,光是把它建起來就辦不到」的時候。

⚙️ 怎麼用(步驟)

書先界定適用邊界:有些類別裡面的問題依賴並不多;如果這些依賴被包含在少數幾個方法中的話,你可以採用子類化並重寫(314 頁)手法來將它們解決掉。但如果依賴猖獗,則這條路可能就行不通了,這時你可能就需要動用介面提取技術,重複運用介面提取來解除對某些特定型別的依賴。依賴下推則是另一個選擇——該手法能夠將目標類別其他部分的問題依賴分離出來,使你能夠更容易地在測試用具中將它實例化(p.307)。

做法概述(p.308):在使用依賴下推技術時,**首先把目標類別設為抽象類別,然後建立一個它的子類別,後者便是你新的產品類別了。接著將所有的問題依賴都「下推」到這個子類別中。**到這一步,你便可以透過子類化原類別來讓它的有關方法接受測試了。

書中的例子是一個 C++ 的交易驗證類別:想修改它裡面的驗證邏輯就會遇到麻煩,因為「我們可不希望將 UI 相關的函式和類別牽扯到測試用具中來」(p.308)。把 UI 相關的工作下推到一個新的子類別之後,就可以再建立另一個測試用的子類別,後者只需實作一個空的顯示訊息方法即可(p.309)。

書中列的步驟(p.310):

  1. 在測試用具中建構目標類別。
  2. 找出哪些依賴是導致建構問題的依賴。
  3. 建立目標類別的子類別,子類別的名字須反映上一步找出的依賴的特徵。
  4. 將目標類別中的依賴變數和方法全部複製到新建的子類別中,注意保持簽名;將目標類別中的相應方法設為受保護及抽象的;將目標類別設為抽象的。
  5. 建立目標類別的一個測試子類別,修改你的測試,實例化該測試子類別。
  6. 建立測試來驗證你的確能實例化這個新的測試子類別。

★★ 在 Go 裡:概念完全成立,只是機制不同

★ 原手法:★★ 把礙事的依賴「下推」到一個新的子類,★ 讓基類變得可測
→ ★ Go 沒有繼承,★★ 但「把依賴推到一個可替換的位置」這個想法完全適用
// ★ 原始問題:一個型別同時有純邏輯與外部依賴
type Reporter struct{ db *sql.DB; smtp *smtp.Client }
func (r *Reporter) Monthly(ctx context.Context, m Month) error {
    rows, _ := r.db.Query(…)          // ★★ 依賴一
    report := buildReport(rows)        // ★ 純邏輯(★ 你真正想測的)
    return r.smtp.Send(report)         // ★★ 依賴二
}
 
// ✅ ★ Go 的「下推」:把兩個依賴推成介面欄位
type Reporter struct {
    data   dataSource       // ★ 未匯出介面,定義在這個 package
    mailer mailer
}
// ★★ 而 buildReport 本來就是純函式 ⇒ ★ 它不需要任何解依賴

★★ Go 的關鍵優勢:★ 「下推」不需要新建型別,只需要換一個欄位型別。

★ Feathers 的做法:★★ 新建一個子類、把依賴移進去、改所有 new 的地方
★ Go 的做法:    ★★ 把 *sql.DB 換成一個 interface ⇒ ★ 零風險(隱式滿足)
  → ★ 真正的實作一行都不用改,★★ 它自動滿足新介面(見 [[介面提取]])

★★ 更徹底的做法:把依賴推到「呼叫端」

// ★★ 最乾淨的形態:★ 型別完全不持有依賴,由呼叫端傳入
func Monthly(ctx context.Context, rows []Row, m Month) (Report, error)   // ★ 純函式
// → ★ 查資料與寄信由 [[服務層]] 負責
// → ★★ 這就是 [[拆分階段]] 的「純核心 + 命令式外殼」

★ 判準:這個依賴「每次呼叫都一樣」還是「每次呼叫可能不同」?
每次都一樣(db、mailer)→ ★ struct 欄位。
每次可能不同(tx、request-scoped 的東西)→ ★★ 方法參數。

★★ 把 tx 放 struct 欄位是一個常見錯誤:★ 那讓那個 struct 變成
「per-request 的」,而它通常被當成單一實例共用(見 表格資料閘道)。

★ 下推之後的檢查:依賴方向有沒有變乾淨

# ★ 領域層不該依賴任何基礎設施
go list -deps ./internal/domain/... | grep -E "database/sql|net/http" && echo "★★ 依賴方向錯了"
# ★ 或用 depguard 把它變成 CI 紅燈(見 [[分離介面]])

🧪 我實際套用的紀錄

  • (待填)

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

書在示範完之後給了明確的自我評價(p.310):

  • 「如此一來我們便有了一個可測試但同時又不依賴於任何 UI 相關事物的類別。那麼,在這個例子中使用繼承是否為理想的方案呢?不是的,但它最大的好處就是能幫助我們將目標類別的一部分邏輯納入測試。」

  • 它是起點而非終點:一旦有了對該類別的測試,便可以開始清理顯示訊息方法裡面的重試邏輯,並將其從那個 UI 子類別提升到基底類別中來;然後,當該 UI 子類別最終被掏空得只剩 UI 相關的呼叫時,我們便可以轉向委託式的設計了——即將 UI 相關呼叫委託給一個 UI 依賴的新類別(p.310)。

  • 不是萬用起手式:依賴少而集中時,書要你先用子類化並重寫(314 頁);依賴猖獗到某些特定型別上時,也可以重複運用介面提取。依賴下推是這兩條路之外的另一個選擇(p.307)。

  • 步驟裡藏著保命細節:搬動時要保持簽名(p.310 步驟 4),且第 6 步要求另外寫一個測試來驗證「你的確能實例化這個新的測試子類別」——這一步本身就是這個手法的成敗判準。

  • 與《重構》的分界:外觀上像在整理繼承體系,但動機是把礙事的依賴掃進一個新子類別,好讓抽象基底類別能在測試用具裡被實例化。書明說用繼承在這裡並非理想方案,只是它能換到「把一部分邏輯納入測試」;設計上的正解(委託)要等測試就位以後才做。

  • ★★ 概念完全成立,機制不同:Go 的「下推」是「把 *sql.DB 換成 interface」,零風險(隱式滿足),真正的實作一行都不用改。

  • ★★ 判準:這個依賴每次呼叫都一樣(→ struct 欄位)還是可能不同(→ 方法參數)?

  • ★★ 把 tx 放 struct 欄位是常見錯誤:那讓 struct 變成 per-request,而它通常被當成單一實例共用。

  • ★ 最乾淨的形態是把依賴推到呼叫端,型別完全不持有依賴(純函式 + 服務層 負責 I/O)。

  • ★ 下推後要檢查依賴方向:go list -deps ./internal/domain/... 不該出現基礎設施套件。

🔗 相關工具