🎯 什麼情境該想到我
當你「一個類別只有欄位加上 getter/setter,卻沒有任何真正的行為,別人都是把它的資料拿出來、在外面自己處理」的時候。
⚙️ 怎麼用(步驟 / 公式)
這味道是什麼徵兆:純資料類別常是「行為被放錯地方」的訊號——操作這些資料的邏輯散落在其他類別裡(往往伴隨依戀情結)。物件導向的精神是把資料與操作它的行為放在一起。
通常用哪些重構手法對治:
- 找出別的地方「拿了這個物件的資料在做的事」,用搬移函式(Move Function)把那些行為搬進來;必要時先提煉函式再搬。
- 封裝集合(Encapsulate Collection):若欄位是集合,別直接暴露,改提供受控的增刪方法。
- 檢視 setter:不該被外部隨意改的欄位,用移除設值函式收斂。
★★ Go 的立場和 Java 完全不同:DTO 是正當的
★ Fowler 的原版是在批評 Java 的「只有 getter/setter 的 anemic 類別」。
在 Go 裡,「只有欄位的 struct」是完全正常、而且常常正確的設計。
// ✅ 這些都不是味道
type Point struct{ X, Y int } // 值型別
type Config struct{ Addr string; Timeout time.Duration } // 設定
type UserResponse struct { // ★ API 的 wire format
ID string `json:"id"`
Email string `json:"email"`
}
type userRow struct{ id, email string } // DB 對映★ Go 沒有「所有欄位都該私有」的文化。time.Time 的欄位是私有的,
但 http.Request、tls.Config、url.URL 都有大量公開欄位——因為它們就是資料。
★ 那什麼時候「只有資料」才是味道?
判準:有邏輯在「別的地方」反覆操作這個型別的欄位。
// ★★ 這才是味道 —— DateRange 只有欄位,於是同一段驗證散落三處
type DateRange struct{ Start, End time.Time }
// service_a.go
if r.End.Before(r.Start) { return errBad }
// service_b.go
if !r.End.After(r.Start) { return errBad } // ★ 邊界條件不一致了
// service_c.go
days := int(r.End.Sub(r.Start).Hours() / 24) // ★ 沒處理 DST
// ✅ 對治:把邏輯搬到資料旁邊([[搬移函式]])
func (r DateRange) Valid() error
func (r DateRange) Days() int
func (r DateRange) Contains(t time.Time) bool★ 所以這個味道在 Go 裡真正的名字是「依戀情結 的另一面」:
不是「struct 沒有方法」有罪,而是**「有邏輯在遠處反覆操作它的欄位」**有罪。
★ 怎麼具體認出來
| 訊號 | 說明 |
|---|---|
| ★ 同一組欄位的檢查散落在多處,而且寫法不一致 | 最可靠的訊號 |
★ 型別名是領域詞彙(Order/Invoice/Money),但沒有方法 | 領域概念應該有行為 |
呼叫端一直在做 if x.A != nil && x.B > 0 | 不變式沒有被封裝 |
有一個 XxxService 專門操作 Xxx 的欄位 | ★ 典型的 anemic domain model |
| 只有 getter/setter,且 setter 沒有驗證 | 封裝是假的 |
★★ 什麼時候這其實不是味道(Go 的重點)
| 情況 | 說明 |
|---|---|
| ★ wire format / DTO | JSON、protobuf、DB row 的對映型別就該只有欄位。把領域邏輯塞進去會讓它依賴序列化細節 |
| ★ 設定型別 | Config、Options —— 它們是一包參數 |
| ★ 值型別(座標、尺寸、顏色) | image.Point 有幾個方法,但即使沒有也合理 |
| ★ 事件/訊息 | 領域事件是「已經發生的事實」,本質上是不可變的資料 |
| ★ 產生的程式碼 | protobuf/sqlc 產生的 struct 不要手動加方法 |
| ★ 表格測試的 case struct | 純資料是刻意的 |
★★ 最重要的一條:Go 的慣例是「領域模型與 wire format 分開」。
UserResponse(純資料)+User(有行為)是兩個型別,不是重複。
硬把它們合成一個,才會製造真正的問題(領域型別依賴 json tag、欄位被迫公開)。
★ 對治的順序
邏輯散落在多處 ──▶ [[搬移函式]](把它搬到資料旁邊)
只有一部分該搬 ──▶ 先 [[提煉函式]] 再 [[搬移函式]]
欄位被到處直接寫,沒有不變式的守點 ──▶ [[封裝變數]] / [[封裝記錄]]
回傳的 slice/map 被呼叫端修改 ──▶ [[封裝集合]]
這個型別其實該不可變 ──▶ 小寫欄位 + 建構函式(走向 [[值物件]])
★ 收手判準:只搬「那些反覆出現在遠處的檢查與計算」。
不要為了「讓 struct 有方法」而硬加 GetName()——那只是把 anemic 換成 noisy。
⚠️ 一個常見的過度反應
// ★ 把 DTO 也「補上行為」 —— 這是錯的方向
type UserResponse struct {
ID string `json:"id"`
}
func (u UserResponse) ChargeMonthlyFee() error { … } // ★★ 現在你的 API 型別會碰資料庫wire format 應該保持笨。 它的工作是「準確反映線上格式」,不是承載業務規則。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
純資料結構有其正當用途:例如某些 DTO、值物件、函式間傳遞的中間結果,本就該是「只有資料」。
-
剛從別處提煉出來、行為尚未搬入的類別,短暫是純資料是正常過渡。
-
★★ 在 Go 裡「只有欄位的 struct」通常不是味道。DTO、Config、事件、值型別、產生的程式碼都該只有資料。
-
★★ 判準是「有邏輯在遠處反覆操作它的欄位」,不是「這個 struct 有沒有方法」。
-
★ 領域模型與 wire format 該是兩個型別。硬合成一個,會讓領域型別依賴 json tag。
-
★ 不要給 DTO 加業務行為。那會讓 API 型別碰到資料庫。
-
★ Go 不需要 Java 式的 getter/setter。
GetName()是非 Go 風格;同 package 直接用欄位是正常的。 -
產生的程式碼(protobuf/sqlc)不要手動加方法,下次 generate 會沖掉。
-
收手判準:只搬「反覆出現在遠處」的邏輯。為了讓 struct 有方法而硬加,只是把 anemic 換成 noisy。
🔗 相關工具
- 依戀情結 Feature Envy(純資料類別最常引發的伴生味道)
- 過大類別 Large Class(把行為搬進來時別讓它膨脹過頭)
- 重構
- 依戀情結 —— ★ 這個味道的另一面;根因相同
- 搬移函式 —— 主要對治手法
- 封裝變數、封裝記錄、封裝集合 —— 不變式需要守點時
- 值物件 —— 不可變資料型別的目標形態
- 領域模型、資料傳輸物件 —— 為什麼該是兩個型別
- 工具-領域邏輯組織模式 —— anemic 與 rich model 的取捨