ValueObject 主要的特色在於其「不變性」和「不具有同一性」。
你的領域建模是否消除了這些特點的實現呢?
知名的軟體業者 Vladimir Khorikov 提到:
if you can’t make a value object immutable, then it is not a value object.
訳:如果你無法讓 ValueObject 不變,那麼它就不是 ValueObject。
本文將專注於 ValueObject 的不變性,
在介紹反模式後,提供一個應有的範例。
※ 這次我認真地從書籍或文章中汲取靈感寫的。
那麼!請開始吧!
因為篇幅有點長,所以先提供一個小結。
「若要引入 VO,請確保值和行為都要進行封裝」
※ 本文不會介紹「實體(Entity)是什麼」以及「DDD是什麼」,因為這偏離主題。
※ 本文使用 Go 語言進行描述,但僅涉及型別定義和簡單的函數,因此容易閱讀。
簡單來說,我將用圖表介紹其概述。

簡而言之,這樣的定義應該可以表達清楚。
對於有些人來說,「同一性」這個詞可能不太清楚,所以我稍微深入說明一下。
※ 「同一」並不等於「獨特」。
我有一個舊圖可以使用。

這個例子中,有一位五歲的小女孩向我提議「絕對要結婚」,
二十年後她想:「他現在在做什麼呢?」並透過社交網路或熟人來尋找我。
(※ 當然,這種事情不會發生在我身上。)
在這個情況下,我的名字「umekikazuya」在二十年後仍然保留著
那時候的「umekikazuya」的同一性,如果她繼續找的話,總會找到我。
因此,在這個範例中,可以將人物視為實體(Entity)。
不過,對於 ValueObject 來說,不具有同一性是其特點。
這個例子可能有點難解釋,但
在這次將實體表現為人物的情況下,可以想到「名字」或「興趣」等。
名字和興趣都是會變的。
(我想像你可以用 updateHobby(input string) 函數 等來更新實體的欄位)
這一節的標題可能有點難以理解,
但實體(Entity)與值對象(ValueObject)的評價(比較)方式有顯著的不同。
對象的評價方式有三種等價方法。
在上述三種中,本文將專注於第二和第三種。
識別子等價性是針對實體的概念,而結構等價性則是針對值對象。
也就是說,
實體是用獨特的 ID 進行比較,而值對象則是用結構進行比較。
這就是其區別所在。
這意味著,
在剛才提到的「絕對要結婚」的例子中,
有個叫〇〇的小女孩找到了 umekikazuya。
她會親自見到本人(umekikazuya),並根據「記憶作為識別子」來評估是否為本人。
這個例子有點難,所以我提供兩個範例。
〇〇這位小女孩在社交媒體上用文字搜尋 umekikazuya。
這次是用「名字(文字串)」進行的結構評估,因此可能會找到多個匹配的帳號。
〇〇這位小女孩在尋找 umekikazuya,並實際上與本人見面以進行確認。
在回憶聊得起勁時,〇〇問道:「你是〇〇幼稚園的吧?」
此時,她會從相簿或記憶中拿出「〇〇幼稚園這個文字串」來對照,並一起評估幼稚園的名稱是否一致。
大概是這樣吧。
或許會有些人有不同的理解(也許是我搞錯了,請留下評論)。
我混合了一些粗糙的例子。
對於概念理解,我認為這些應該足夠了。
那麼,讓我們帶入反模式,查看兩個實作範例吧。
在介紹反模式之前,
輕輕地提供此次涉及的實體的領域建模作為前提。
type User struct {
id UserID
email Email
}
這是一個擁有 id 欄位 和 email 欄位 的簡單實體案例。
這是一個很好的例子,因為它將每個屬性包裝為值對象,而不是單純的 string。
不過,我不會深入探討,但我會提到此階段的反模式。
type User struct {
id string
email string
}
type UserID string
type Email string
看到這些時,是否滿意於「透過型別為領域進行了表現」呢?
遺憾的是,此次的 VO 無法為 userID 和 Email 保障領域的不變性。
VO 的核心責任是「保障和封裝領域的不變條件(invariant)」。
僅定義 type 的實作成為反模式的原因有兩個。
像 type Email string 這樣的定義,在 Go 的情況下,可以輕易通過「型別轉換」繞過驗證。
func NewEmail(addr string) (Email, error) {
if !isValidEmail(addr) {
// 驗證處理
return "", errors.New("invalid email")
}
return Email(addr), nil
}
// 即使準備了工廠函數(建構子),
// 也可以從包外強制創建非法值
invalidEmail := email.Email("只是一個字符串")
這樣的設計無法保證 Email 型別的值始終是「有效的電子郵件地址」。
其他包可以實例化非法的電子郵件地址對象。
type Email string 的定義使得該型別幾乎只有 string 的能力(易於轉換為 string)。
VO 不僅僅是「經過驗證的值」,它還負責封裝與該值相關的「領域特有的行為(邏輯)」。
例如,如果有需求想從「電子郵件地址」中獲取域部分,現有設計的實作將位於應用層。
這種「僅獲取電子郵件的域部分」的 "對象行為" 是應關注的分離主題。
在設計和編碼的過程中,這應該是在領域層中進行,而不是在應用層中。
讓我們進行重構。
需要強制實現「封裝」和「不變性」的設計。
借助結構體來表示相應的 VO,並封裝值與行為。
type Email struct {
value string
}
// NewEmail 是公開的工廠函數
func NewEmail(in string) (Email, error) {
err := Email{value: in}.validate()
if err != nil {
return Email{}, errors.New("invalid email")
}
return Email{value: in}, nil
}
// Value 是 getter 函數
func (e Email) Value() string {
return e.value
}
// GetDomain 是僅獲取電子郵件域部分的函數(簡單的邏輯尚請見諒)
func (e Email) getDomain() string {
parts := strings.Split(e.value, "@")
if len(parts) == 2 {
return parts[1]
}
// (不變條件應該已經被保證)
return ""
}
(這可能是 Go 獨有的情況)
在選擇使用指針接收器還是值接收器時,常常會感到困惑。
基本上,我認為不變的對象應該使用值接收器。
// 不良範例(指針接收器)
func (input *Email) validate() error { ... }
// 良好範例(值接收器)
func (input Email) validate() error { ... }
如果 VO 擁有指針接收器,則會暗示「值是可以改變的」,
這可能會破壞其不變性。
總結一下要點。
要點:
value),這樣可以防止從包外進行型別轉換或直接賦值。NewEmail 進行實例化,並保障驗證。e Email),以明確不變性。我認為「僅存在的型別定義」無法強制領域規則,是「虛假的 VO」。
在需要不變性的場景中,應徹底進行封裝,
並設計出不允許違反領域規則的對象生成方式。
通過工廠函數生成的實例應遵守領域規則。
唯有如此,VO 才能成為「有意義的」存在。
讓我們一起守護 VO 吧。
以上!感謝!
我發現以下這些文章對我很有幫助。
此外,如果想要更深入的學習,以下的書籍也很推薦(我目前也只讀到一半)。
原文出處:https://qiita.com/umekikazuya/items/e8567659dc06b07cc9cd