🎯 什麼情境該想到我

當你「發現同一個模組(或類別)常常因為好幾種完全不同的原因而被修改」的時候——例如加一種新產品要改它、換一個資料庫也要改它。

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

這味道是什麼徵兆:一段程式碼理想上「只因一種原因而改」。當你為了不同理由一次次回到同一個地方修改,代表它把多個不相關的職責(不同變化軸)混在一起了。這與「霰彈式修改」正好相反:發散是「一個模組、多種變化原因」。

通常用哪些重構手法對治:

  • 拆分階段(Split Phase):若這兩種原因對應處理流程的不同階段(如先解析、再計算),把它們切成前後兩段。
  • 搬移函式(Move Function):把處理不同原因的函式搬去各自合適的模組。
  • 提煉類別(Extract Class):把每一種變化原因各自封裝成一個類別。

目標:讓每個處理某一種變化的情境都集中在單一模組裡。

★ 定義:一個模組因為「好幾個不同的原因」而需要修改

★ 發散式變化:改 1 個需求 → 動 1 個檔案的好幾處(一個檔案服務多個變化軸)
★ 霰彈式修改:改 1 個需求 → 動 N 個檔案(一個變化軸散落多個檔案)
              ↑ 兩者互為對偶,而且修其中一個常常會製造另一個

★ 怎麼具體認出來:用 git 歷史,不要靠感覺

# ★ 最可靠的偵測方式:看同一個檔案在不同類型的 commit 裡出現的頻率
git log --format='%s' --name-only -- internal/service/order.go |   grep -v '^$' | grep -v 'order.go' | sort | uniq -c | sort -rn | head
#   ★ 如果 order.go 出現在「加新支付方式」「換資料庫」「改稅務規則」三類 commit 裡
#     → 它服務三個變化軸,這就是發散式變化
 
# 反過來看:一個檔案的 commit message 主題有幾類
git log --format='%s' -- internal/service/order.go | head -30

★ 這比任何靜態分析都準,因為「變化軸」是歷史事實,不是結構特徵。

其他訊號:

  • 一個檔案同時 import 了 database/sql、net/http、text/template
    → ★ 它同時管持久化、傳輸、呈現(三個變化軸)。
  • 型別名含糊(Manager、Service、Handler)。
  • Code review 時你發現「這個 PR 改了 order.go,上一個 PR 也改了 order.go,但兩者毫無關係」。

★ 對治:按「變化的原因」切,而不是按「技術層」切

// ❌ 一個型別服務三個變化軸
type OrderService struct{ db *sql.DB }
func (s *OrderService) CalculatePrice(o Order) float64  // ← 定價規則會變
func (s *OrderService) SaveToDB(o Order) error           // ← 資料庫會換
func (s *OrderService) RenderInvoice(o Order) []byte     // ← 樣板會變
 
// ✅ 三個變化軸各自一個地方
package pricing  ; func Calculate(o Order) float64
package orderrepo; func (r *Repo) Save(ctx, o Order) error
package invoice  ; func Render(o Order) []byte

動作順序:

  1. 拆分階段——如果變化軸剛好對應「解析 → 計算 → 輸出」。
  2. 提煉類別/提煉函式——把各軸的邏輯收攏。
  3. ★ 拆 package——Go 真正的封裝邊界(見 過大類別)。

★★ 什麼時候這其實不是味道

情況說明
★ 那個「模組」本來就是組裝點main.go/wire.go/router.go 必然因為任何新功能而改。那是它的工作
★ 型別本身就是多個概念的交會http.Request 因為 HTTP 規格的任何部分改變而改——但那是一個規格,不是多個軸
變化軸雖多但都很少發生一年改兩次,拆三個 package 的成本更高
★ 拆了會製造 霰彈式修改這是最重要的一條:兩個味道互為對偶

★★ 判準:你是在「減少每次改動要碰的地方」,還是只在「搬移麻煩」?
把一個檔案拆成三個,結果每次改功能都要同時改三個 → 你只是把發散式變化換成霰彈式修改。
只有當變化軸真的彼此獨立(改 A 不需要碰 B)時,拆才有收益。

Go 的實務做法:用依賴方向鎖住變化軸

// ★ 讓「會變的」依賴「穩定的」,而不是反過來
package pricing                        // ★ 穩定:純業務規則,不 import 任何基礎設施
func Calculate(o Order) Money
 
package orderhttp                      // ★ 會變:依賴 pricing,pricing 不知道它存在
func Handler(calc func(Order) Money) http.HandlerFunc

這就是 工具-好萊塢原則與依賴反轉 在檔案層級的樣子:
業務規則不該因為「換了 HTTP 框架」而重新編譯。

🧪 我實際套用的紀錄

  • (待填)

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

  • 拆分要基於「真的會各自獨立變化」的職責,別為了教條把本來內聚的東西硬拆散。

  • 若某種「變化原因」其實很少發生、也不複雜,維持現狀可能更簡單。

  • ★★ 拆解可能製造 霰彈式修改。判準是「變化軸是否真的獨立」——不獨立就別拆。

  • ★ 組裝點(main.go、router.go、DI 設定)必然因任何新功能而改,那不是味道。

  • 變化軸很少發生時,拆的成本高於收益。 先用 git 歷史確認頻率。

  • ★ 不要按「技術層」切,要按「變化的原因」切。models/、services/、handlers/ 這種分層,讓「加一個欄位」要動三個資料夾——那就是霰彈式修改。

  • Go 拆 package 會撞循環 import,要先想好依賴方向(見 搬移函式)。

  • 靠感覺判斷不準,用 git log。 「哪些 commit 改了這個檔案」是唯一可靠的證據。

🔗 相關工具