[Design] Value Object

Cyan Ho
8 min readFeb 20, 2023
Photo by vadim kaipov on Unsplash

Value Object(值物件)是物件導向程式裡存在已久的一個設計模式,主要會用來對同質性的實體進行建模。舉例來說,用百元紙鈔買東西時,百元紙鈔就是一種同質性的實體,儘管每張百元紙鈔都有不同的流水編號,但在消費的場景下,對於買賣雙方來說,這些百元紙鈔彼此之間並無差別,每一張都具有同等的效用。

近期因為 DDD(Domain-Driven Design)的興起,Eric Evans 在他的經典小藍書著作裡的建模章節,特別介紹了 Value Object 設計模式,讓 Value Object 重新獲得許多關注,筆者也是在研究 DDD 的過程中,漸漸地感受到 Value Object 實用的地方,本篇文章主要記錄了筆者近期對於 Value Object 的了解和體會。

一個簡單的例子:平面座標點(Point)

一個平面座標點(以下簡稱座標)會由 x 和 y 座標所組成,在這個例子裡,可以看到 Value Object 在表達實體的同質性,以及封裝概念完整性的能力:

// TypeScript

class Point {
public constructor(
public x: number,
public y: number
) {}

public isEqual(p: Point) {
return (
this.x === p.x &&
this.y === p.y
)
}
}

在程式中,每個物件都會有自己的記憶體位置,本質上是不同的東西,但對於 Value Object 來說,它只在意物件身上的屬性,以座標為例,只要 x 和 y 座標皆相同,兩個座標就是相等的:

// TypeScript

const p1 = new Point(3, 4)
const p2 = new Point(3, 4)

p1 === p2 // false
p1.isEqual(p2) // true

而關於 Value Object 封裝概念完整性的能力,可以比較看看使用前後的差異:

// TypeScript

// Primitives
function distance(
p1x: number
p1y: number,
p2x: number,
p2y: number
): number;

// Value Object
function distance(
p1: Point,
p2: Point
): number;

Value Object 不單單只是將概念相同的資料集合起來,隨著程式碼日漸複雜,Value Object 還提供了一個絕佳的邏輯封裝位置:

// TypeScript

class Point {
/* ... */
public distance(p: Point) { /* ... */ }
public slope(p: Point) { /* ... */ }
/* ... */
}

const p1 = new Point(3, 4)
const p2 = new Point(6, 8)

p1.distance(p2) // 5
p1.slope(p2) // 1.33

除了座標之外,Value Object 也常常被用來封裝貨幣(幣種、面額)、地址(市、區、街、巷)等等,讓原本散落四處的資料,能以一個完整的實體呈現在程式中。

更靈活的應用

前面提及的例子,都在展示如何將多個欄位封裝成 Value Object,但 Value Object 的應用不限於此,將單個值建模成 Value Object 也能帶來許多效益。

系統邊界常常會有驗證外界輸入(input)的需求,以確保進到系統內部處理的資料都是符合格式和規則的。在主流的分層式系統架構下,開發者或多或少都曾經糾結過要將資料驗證的邏輯放在哪裡比較合適,像是 Controller 層、Service 層或是最核心的 Domain 層。

從最外面的 Controller 層下手是一個合理的作法,代表著所有 Service 層以內的系統所接收到的資料格式基本上都是正確的。儘管有這樣的認知,但在開發系統內層元件時,不免還是會有一些疑慮產生:「在 Domain 物件裡是否要再檢查一次資料呢?可以確保 Controller 層不小心漏掉時,讓 Domain 物件做最後一次把關。」

以註冊帳號時需要 Email 資料為例,上述的情境表示為程式碼後大約會像這樣:

// TypeScript

// Controller
function signup(req: Request) {
validate(req.body.email)
const service = new SignupService(/* ... */)
return service.signup(req.body.email)
}

// Service
class SignupService {
public signup(email: string) {
const account = new Account()
account.setEmail(email)
// Save account into database
// ...
}
}

// Domain
class Account {
public email: string
public setEmail(email: string) {
// The `email` argument seems like a plain string with any possible format.
//
// Although we know we would validate the email in the Controller layer,
// should we validate it again here to ensure it mustn't go wrong?
this.email = email
}
}

儘管心理上知道在系統外層會負責驗證 Email 格式,但在內層元件裡看到 Email 時依舊是任意格式的字串型別,心裡總是有點不踏實。有趣的是,Value Object 可以巧妙的解決這個問題,同樣的例子,用 Value Object 來對 Email 建模後,會像這個樣子:

// TypeScript

// Email Value Object
class Email {
public value: string
public constructor(email: string) {
// Encapsulate validation into Value Object
validate(email)
this.value = email
}
}

// Controller
function signup(req: Request) {
// Will throw error when email is invalid
const email = new Email(req.body.email)
const service = new SignupService(/* ... */)
return service.signup(email)
}

// Service
class SignupService {
public signup(email: Email) {
const account = new Account()
account.setEmail(email)
// Save account into database
// ...
}
}

// Domain
class Account {
public email: Email
public setEmail(email: Email) {
// We can see `email` argument now must be a valid email,
// we don't need to bother to validate it again or not.
this.email = email
}
}

Email 本質上只是一個字串,透過 Value Object 對它建模,能夠將和 Email 相關的特性與邏輯一同封裝進去,在系統內部接收 Email 參數時,可以很確定它一定符合基本的格式與規則。

很多常見的資料,像是 Email、帳號、密碼等等,通常都會直覺地把他們當作字串處理,它們本質上的確是字串,但隨著業務的需求變化,這些字串常常會被附加額外的規則,例如帳號不能包含特殊符號、密碼需要 hash 過才能存進資料庫等等,這些規則讓它們變得不單純只是字串,透過 Value Object 可以巧妙地將資料本身和資料邏輯封裝在一起,形成更有業務意義的單位。

結語

了解 Value Object 設計模式以及應用情景後,在設計系統時就多了一個工具能夠使用,若能適當地使用 Value Object 將概念一致的資料與邏輯封裝成一個完整的單位,能讓程式碼傳達出更多有意義的訊息。

但也不該一股腦地將所有資料都套上 Value Object,當基本的資料型態就足以表示,多包裝一層物件只會自縛手腳,增加開發成本而得不到任何益處。如何取得適當的平衡,是一個需要不斷磨練的課題。

--

--