🎯 什麼情境該想到我
當你「兩個模組(類別)之間私下互通太多彼此的內部細節,關起門來交換大量資料」的時候——它們對彼此的了解遠超過該有的程度。
⚙️ 怎麼用(步驟 / 公式)
這味道是什麼徵兆:模組之間過度熟悉彼此內部,會堆出高得驚人的耦合,讓任何改動都得同時顧到兩邊。作者比喻:模組間難免要交流,但要把這種「交易」降到最低、攤在檯面上。
通常用哪些重構手法對治:
- 搬移函式(Move Function)/搬移欄位(Move Field):把糾纏的資料與行為重新分配,減少跨模組的私下往來。
- 隱藏委託關係(Hide Delegate):讓一個模組不必直接知道另一個的內部,改透過中介。
- 以委託取代子類 / 以委託取代父類(Replace Subclass/Superclass with Delegate):若耦合源自繼承(子類過度依賴父類內部),改用委託拉開距離。
★ 定義:兩個模組私下交換太多資訊,形成看不見的耦合
★★ Go 裡的五種具體形態
// ① ★ 跨 package 存取「本來不該公開」的東西
// 為了讓 billing 用到,把 order.internalState 改成 InternalState 公開出去
// → 從此 order 不敢改它,因為不知道誰在用
// ② ★ 雙向依賴(Go 會直接編譯失敗,這是好事)
package a → import b
package b → import a // ★ import cycle not allowed
// → 於是有人建一個 shared / common package 來「解決」它 → 見形態 ③
// ③ ★★ common / shared / utils package —— 最常見、最難救的一種
package common
type Order struct{…} // ★ 被 billing、shipping、reporting 三方共用
// → 改一個欄位要協調三個團隊;沒人擁有它,所以它只會越長越大
// ④ 測試偷看實作
func TestX(t *testing.T) {
s := New()
s.internalCache["k"] = v // ★ 測試依賴私有狀態 → 重構實作必然弄壞測試
}
// ⑤ ★ 共用資料庫表
// 兩個服務直接讀寫同一張表 → 那張表的 schema 就是它們之間的 API,
// 但沒有版本、沒有契約、沒人負責★ 怎麼具體認出來
# ① 找出被最多 package 依賴的「共用」package —— 通常就是內幕交易的集中地
go list -deps ./... 2>/dev/null | sort | uniq -c | sort -rn | head
# ② 誰 import 了它
grep -rn '"yourmodule/internal/common"' --include='*.go' . | wc -l
# ③ golangci-lint 的 depguard 可以把「禁止的 import」變成 CI 檢查(2026-09-24 查證)★ Go 有一個內建的強力工具:internal/ 目錄。
a/internal/x 只能被 a/ 及其子樹 import——這是編譯器層級的邊界,不是約定。
用它把「不該被外面碰的東西」鎖起來。
★ 對治手法
| 形態 | 對治 |
|---|---|
| 為了別人而公開的欄位 | 搬移函式——把邏輯搬到資料那邊,就不必公開欄位 |
| 雙向依賴 | ★ 介面定義在消費方(見 工具-針對介面編程)——單向依賴,不需要 shared package |
common package | ★ 按領域把型別複製回各自的 package。兩個 Order 勝過一個上帝型別(見 重複程式碼 的「巧合重複」) |
| 測試偷看實作 | 改成從公開 API 觀察行為(工具-特徵測試) |
| 共用資料庫表 | 一張表只有一個服務可以寫;其他人透過 API 或事件 |
| 過度轉發 | 隱藏委託關係 做過頭就變成 中間人——反向公開委託物件 |
★★ 什麼時候這其實不是味道
| 情況 | 說明 |
|---|---|
| ★ 同一個 package 內 | Go 的封裝邊界是 package。同 package 的型別互相存取私有欄位是設計意圖,不是內幕交易 |
| ★ 刻意的緊密協作者 | sql.Rows 與 sql.Stmt、http.Request 與 http.Response —— 它們本來就是一組 |
| 真正共用的基礎設施 | pkg/log、pkg/metrics 這種穩定、無業務語義的東西,共用是對的 |
| ★ 產生的程式碼 | protobuf/sqlc 產生的共用型別是契約,由 schema 擁有,不是無主的 shared |
★★ 判準:這個共用的東西「有沒有主人」,以及「會不會因為業務需求而改變」。
有主人且穩定(pkg/log)→ 共用是對的。
無主且會因業務而改(common.Order)→ 這是內幕交易。
★ 最重要的一段:common package 為什麼一定會失敗
第 1 週:billing 和 shipping 都需要 Order → 放進 common
第 4 週:billing 需要 Order.TaxID → 加上去(shipping 用不到)
第 8 週:shipping 需要 Order.Weight → 加上去(billing 用不到)
第 20 週:common.Order 有 40 個欄位,任何改動都要通知三個團隊,
而且沒有人能回答「這個欄位是給誰用的」
★ 根因:
common是按「技術上相同」分組的,不是按「一起變化」分組的。
訂單在計費眼中是「金額與稅務」,在物流眼中是「重量與地址」——
它們是兩個概念,剛好共用一個名字。
→ 正確做法是各自擁有自己的型別,在邊界上做轉換(這就是 DDD 的 bounded context 與 防腐層)。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
模組間本來就需要協作,適度的介面往來是正常的;味道指的是「過度」與「私下走內部」。
-
★ 同一個 package 內互相存取私有欄位不是味道,那是 Go 的封裝設計。
-
★ 穩定、無業務語義的共用基礎設施(log、metrics)該共用;會因業務而變的型別不該。
-
★★
common/shared/utilspackage 幾乎一定會腐敗,因為它按「技術相同」分組而非按「一起變化」分組。 -
★ 用
internal/目錄建立編譯器層級的邊界,不要只靠約定。 -
★ 解雙向依賴不要靠建第三個 package,要靠「介面定義在消費方」。
-
拆掉
common會製造看起來像重複的程式碼——那通常是對的(巧合重複),但要跟團隊講清楚為什麼。 -
共用資料庫表是最難拆的一種。拆之前先確認「誰是唯一的寫入者」。
🔗 相關工具
- 依戀情結 Feature Envy(對別人資料過度上心的近親味道)
- 訊息鏈 Message Chains/中間人 Middle Man(同屬耦合過度的一族)
- 被拒絕的遺贈 Refused Bequest(繼承造成的過度耦合)
- 重構
- 搬移函式 —— 把邏輯搬到資料那邊,就不必公開欄位
- 隱藏委託關係、中間人 —— 過度暴露與過度隱藏
- 工具-針對介面編程 —— 用「介面定義在消費方」解雙向依賴
- 防腐層 —— 邊界上的型別轉換
- 重複程式碼 —— 拆掉 common 會製造的「巧合重複」
- 工具-特徵測試 —— 測試該從公開 API 觀察