🎯 什麼情境該想到我
當你「發現同一個模組(或類別)常常因為好幾種完全不同的原因而被修改」的時候——例如加一種新產品要改它、換一個資料庫也要改它。
⚙️ 怎麼用(步驟 / 公式)
這味道是什麼徵兆:一段程式碼理想上「只因一種原因而改」。當你為了不同理由一次次回到同一個地方修改,代表它把多個不相關的職責(不同變化軸)混在一起了。這與「霰彈式修改」正好相反:發散是「一個模組、多種變化原因」。
通常用哪些重構手法對治:
- 拆分階段(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動作順序:
★★ 什麼時候這其實不是味道
| 情況 | 說明 |
|---|---|
| ★ 那個「模組」本來就是組裝點 | 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 改了這個檔案」是唯一可靠的證據。
🔗 相關工具
- 霰彈式修改 Shotgun Surgery(本味道的對照面:一種變化散落多個模組)
- 過大類別 Large Class(職責太多常同時引發發散式變化)
- 重構
- 霰彈式修改 —— ★ 對偶的味道;修這個常製造那個
- 提煉類別、拆分階段 —— 主要對治手法
- 過大類別 —— 常常同時出現
- 搬移函式 —— 拆 package 時的循環 import 對策
- 工具-好萊塢原則與依賴反轉 —— 用依賴方向鎖住變化軸
- 工具-封裝變化點 —— 「哪裡會變」的識別