🎯 什麼情境該想到我

當你「發現某一層的所有類別都有共同的行為或欄位、想收攏起來」的時候。

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

意圖:讓某一層的所有類別共用一個父類別,收納該層共同的行為與欄位。

做法要點:

  • 為該層建立一個共同父類別(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(見 內幕交易 的同一機制)。

  • 不要為了「有一個基礎型別」而建立層超型。 兩三個實體共用兩個欄位時,重複比抽象便宜。

🔗 相關工具