🎯 什麼情境該想到我

當你「兩個模組(類別)之間私下互通太多彼此的內部細節,關起門來交換大量資料」的時候——它們對彼此的了解遠超過該有的程度。

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

這味道是什麼徵兆:模組之間過度熟悉彼此內部,會堆出高得驚人的耦合,讓任何改動都得同時顧到兩邊。作者比喻:模組間難免要交流,但要把這種「交易」降到最低、攤在檯面上。

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

  • 搬移函式(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 / utils package 幾乎一定會腐敗,因為它按「技術相同」分組而非按「一起變化」分組。

  • ★ 用 internal/ 目錄建立編譯器層級的邊界,不要只靠約定。

  • ★ 解雙向依賴不要靠建第三個 package,要靠「介面定義在消費方」。

  • 拆掉 common 會製造看起來像重複的程式碼——那通常是對的(巧合重複),但要跟團隊講清楚為什麼。

  • 共用資料庫表是最難拆的一種。拆之前先確認「誰是唯一的寫入者」。

🔗 相關工具