🎯 什麼情境該想到我

當你「一個類別只有欄位加上 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 / DTOJSON、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。

🔗 相關工具