🎯 什麼情境該想到我
當你「發現某一層的所有類別都有共同的行為或欄位、想收攏起來」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:讓某一層的所有類別共用一個父類別,收納該層共同的行為與欄位。
做法要點:
- 為該層建立一個共同父類別(supertype),例如所有領域物件共用的 id、相等性判斷等。
- 把重複的行為與欄位上移到父類別,子類別只保留各自的差異。
- 一層可能有不只一個層超型,視共同性而定。
★★ Go 沒有繼承,所以這個模式要換成三種寫法
// ★ 原始形態(Java):每一層有一個共同的基礎類別
abstract class DomainObject {
Long id;
Long getId() { … }
boolean equals(Object o) { … } // ★ 共同的相等性判斷
}
class Order extends DomainObject { … }★★ Go 沒有繼承,而嵌入沒有虛擬分派(見 工具-Go嵌入機制),
所以「層超型」在 Go 裡拆成三個不同的東西,各有適用場景。
★ Go 的三種替代
// ★★ ① 嵌入一個未匯出的共用 struct(★ 只共用「資料 + 純存取」)
type entityBase struct {
id string
createdAt time.Time
version int64
}
func (b entityBase) ID() string { return b.id } // ★ 純存取,安全
func (b entityBase) Version() int64 { return b.version }
type Order struct {
entityBase // ★ 嵌入
status Status
}
// ★★ 安全前提:entityBase 的方法「不呼叫任何預期被覆寫的方法」
func (b entityBase) Describe() string { return b.Kind() + … } // ★★ 錯!沒有虛擬分派
// ★ ② 介面(共用「行為契約」,不共用實作)
type Entity interface {
ID() string
Version() int64
}
// ★★ ③ 泛型(Go 1.18+)—— ★ 共用「演算法」而非資料
func SaveAll[T Entity](ctx context.Context, q db.Querier, items []T) error { … }★★ 判準:你想共用什麼?
★ 資料 + 純存取方法 → ① 嵌入
★ 行為契約 → ② 介面
★★ 演算法(對任何實體都一樣的邏輯)→ ③ 泛型(★ 這是 Go 最乾淨的答案)
★★ 它最常見的濫用:變成一個什麼都塞的基礎型別
// ★★ 腐敗過程
type baseEntity struct {
id, createdAt, updatedAt // ★ 第 1 週:合理
deletedAt *time.Time // ★ 第 4 週:軟刪除(★ 但不是所有實體都需要)
tenantID string // ★ 第 8 週:多租戶(★ 有些實體是全域的)
auditLog []AuditEntry // ★★ 第 12 週:稽核(★ 現在每個實體都背著它)
cache map[string]any // ★★★ 第 20 週:無藥可救
}★★ 判準:這個欄位「所有」實體都需要嗎?
不是所有 → 不要放層超型。
★ Go 的嵌入讓這個濫用特別容易:加一個欄位到
baseEntity,
所有嵌入它的型別都自動有了,而且不會有任何編譯錯誤提醒你「這個實體不需要它」。
→ ★★ 這正是 內幕交易 裡「common package 一定會腐敗」的同一個機制。
★★ 一個具體的反例:軟刪除放層超型
type baseEntity struct{ deletedAt *time.Time }
// ★★ 後果:
// ① ★ 每個查詢都要記得加 WHERE deleted_at IS NULL —— ★ 漏一個就洩漏已刪資料
// ② ★★ 唯一索引失效:email 唯一,但「已刪除的」也佔著
// → 要改成 partial unique index WHERE deleted_at IS NULL
// ③ ★ 外鍵仍然指向已刪除的列
// ④ ★★ 有些實體根本不該軟刪除(稽核紀錄不該能被刪)
// → ★ 軟刪除是一個「需要逐個實體決定」的設計,不是共用的基礎設施★ 這是「層超型不該放什麼」最好的例子:它看起來共通,實際上每個實體的語義不同。
★★ 什麼時候層超型是對的
✅ ★ 純資料 + 純存取(id、created_at、version)—— ★ 而且真的所有實體都有
✅ ★★ 演算法共用用泛型(SaveAll[T]、FindByID[T])—— ★ 這是最沒有副作用的形態
✅ ★ 行為契約用介面(Entity、Auditable)—— ★ 而且介面要小
❌ ★★ 任何「不是所有實體都需要」的東西
❌ ★★ 任何需要「子型別覆寫」的骨架 —— ★ Go 做不到,用函式欄位注入
★ 兄弟辨析
| 差別 | |
|---|---|
| 分離介面 | ★ 介面與實作放在不同 package(依賴方向) |
| 繼承對應器 | ★ 持久化層的層超型特化 |
| 工具-Go嵌入機制 | ★ 嵌入的完整規則與陷阱 |
| 範本方法模式 | ★ 需要覆寫的骨架 —— ★★ 在 Go 不成立 |
| 提煉超類 | 重構手法:什麼時候該抽出共同型別 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
若該層只有單一類別,就不需要層超型。
-
別把不相干的共同點硬塞進同一父類別,只放真正屬於這一層的共通性。
-
小心繼承過深;只是為了共用少量程式碼時,考慮組合或工具類別。
-
★★ Go 的嵌入沒有虛擬分派。共用 struct 的方法不可以呼叫預期被覆寫的方法。需要骨架 + 細節就用函式欄位注入。
-
★★ 判準:這個欄位「所有」實體都需要嗎? 不是所有 → 不要放層超型。
-
★★ Go 的嵌入讓濫用特別容易:加一個欄位,所有嵌入它的型別都自動有了,而且沒有編譯錯誤提醒你不需要它。
-
★★ 軟刪除不該放層超型:每個查詢要記得加條件、唯一索引失效(要 partial index)、外鍵仍指向已刪列、有些實體根本不該能被刪。
-
★ 共用「演算法」用泛型是 Go 最乾淨的答案(
SaveAll[T Entity]),副作用最小。 -
★ 共用「行為契約」用介面,而且介面要小。
-
★ 層超型會變成無主的 common package(見 內幕交易 的同一機制)。
-
不要為了「有一個基礎型別」而建立層超型。 兩三個實體共用兩個欄位時,重複比抽象便宜。
🔗 相關工具
- 特例 Special Case(以子類別封裝特殊情況,常搭配層超型)
- 分離介面 Separated Interface(用介面而非繼承來解相依)
- 企業應用架構模式
- 分離介面 —— 介面與實作的 package 分離
- 繼承對應器 —— 持久化層的特化
- 範本方法模式 —— ★ 需要覆寫的骨架在 Go 不成立
- 提煉超類 —— 何時該抽出共同型別
- 工具-Go嵌入機制 —— 嵌入的完整規則
- 內幕交易 —— 共用基礎型別腐敗的機制
- 工具-優先組合而非繼承 —— 為什麼 Go 選了組合