Value Object(值物件)

是什麼?

Value Object 是用來定義的不可變物件。兩個 Value Object 如果所有屬性值都相同,就視為相等。它沒有唯一識別(ID),也不追蹤生命週期。

ℹ️DDD 建構塊

Value Object 是 DDD 中最被低估的模式。大量使用 Value Object 取代原始型別(string、int、decimal),能顯著提升程式碼的型別安全性和可讀性。

核心觀念

常見誤區

⚠️常見誤區

誤區一:因為 Value Object 「沒有 ID」就覺得不重要。實際上好的 DDD 程式碼中 Value Object 的數量通常遠多於 Entity。

誤區二:讓 Value Object 變成 mutable(可變的)。一旦可以修改,就失去了 Value Object 最核心的特性:安全的共享和值相等。

誤區三:把 Value Object 存到資料庫時給它一個獨立的 ID 和獨立的表。Value Object 應該被內嵌(embedded)在 Entity 的表中,或用 JSON 欄位存儲。

設計流程

1

識別原始型別濫用

找出程式碼中用 string、int、decimal 表示業務概念的地方

2

設計 Value Object

定義屬性和建構式,加入驗證邏輯

3

實作不可變性

所有屬性設為唯讀,修改操作回傳新物件

4

實作值相等

覆寫 Equals 和 GetHashCode,比較所有屬性

5

替換原始型別

把 Entity 中的 string/int 替換為對應的 Value Object

程式碼範例

C# 版本

csharp
// Value Object 基底類別
public abstract class ValueObject
{
    protected abstract IEnumerable<object> GetEqualityComponents();
 
    public override bool Equals(object? obj)
    {
        if (obj is not ValueObject other) return false;
        return GetEqualityComponents()
            .SequenceEqual(other.GetEqualityComponents());
    }
 
    public override int GetHashCode()
    {
        return GetEqualityComponents()
            .Aggregate(0, (hash, component) =>
                HashCode.Combine(hash, component));
    }
}
 
// 具體 Value Object:金額
public class Money : ValueObject
{
    public decimal Amount { get; }
    public string Currency { get; }
 
    public Money(decimal amount, string currency)
    {
        if (amount < 0) throw new ArgumentException("Amount cannot be negative.");
        if (string.IsNullOrWhiteSpace(currency)) throw new ArgumentException("Currency is required.");
        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }
 
    // 無副作用:回傳新物件
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Cannot add different currencies.");
        return new Money(Amount + other.Amount, Currency);
    }
 
    public static Money Zero(string currency = "TWD") => new(0, currency);
 
    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Amount;
        yield return Currency;
    }
}
 
// 具體 Value Object:Email
public class Email : ValueObject
{
    public string Value { get; }
 
    public Email(string value)
    {
        if (!value.Contains('@'))
            throw new ArgumentException("Invalid email format.");
        Value = value.ToLowerInvariant();
    }
 
    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Value;
    }
}
 
// 使用
var price1 = new Money(100, "TWD");
var price2 = new Money(100, "TWD");
Console.WriteLine(price1.Equals(price2)); // true — 值相等
 
var total = price1.Add(new Money(50, "TWD")); // 回傳新的 Money(150, "TWD")

TypeScript 版本

typescript
// Value Object:金額
class Money {
  private constructor(
    public readonly amount: number,
    public readonly currency: string,
  ) {}
 
  static create(amount: number, currency: string): Money {
    if (amount < 0) throw new Error("Amount cannot be negative.");
    if (!currency) throw new Error("Currency is required.");
    return new Money(amount, currency.toUpperCase());
  }
 
  static zero(currency = "TWD"): Money {
    return new Money(0, currency);
  }
 
  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error("Cannot add different currencies.");
    }
    return Money.create(this.amount + other.amount, this.currency);
  }
 
  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}
 
// Value Object:Email
class Email {
  public readonly value: string;
 
  constructor(value: string) {
    if (!value.includes("@")) throw new Error("Invalid email format.");
    this.value = value.toLowerCase();
  }
 
  equals(other: Email): boolean {
    return this.value === other.value;
  }
}
 
// 使用
const price1 = Money.create(100, "TWD");
const price2 = Money.create(100, "TWD");
console.log(price1.equals(price2)); // true

Python 版本

python
from dataclasses import dataclass
 
# Value Object 用 frozen=True 實現不可變性
@dataclass(frozen=True)
class Money:
    amount: float
    currency: str
 
    def __post_init__(self):
        if self.amount < 0:
            raise ValueError("Amount cannot be negative.")
        if not self.currency:
            raise ValueError("Currency is required.")
        # frozen=True 下要用 object.__setattr__
        object.__setattr__(self, "currency", self.currency.upper())
 
    def add(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError("Cannot add different currencies.")
        return Money(self.amount + other.amount, self.currency)
 
    @staticmethod
    def zero(currency: str = "TWD") -> "Money":
        return Money(0, currency)
 
@dataclass(frozen=True)
class Email:
    value: str
 
    def __post_init__(self):
        if "@" not in self.value:
            raise ValueError("Invalid email format.")
        object.__setattr__(self, "value", self.value.lower())
 
# 使用
price1 = Money(100, "TWD")
price2 = Money(100, "TWD")
print(price1 == price2)  # True — frozen dataclass 自動比較所有欄位
 
total = price1.add(Money(50, "TWD"))  # Money(150, "TWD")

概念圖

Value Object
必須具備
不可變性
值相等
自我驗證
Entity
包含
原始型別 (string, int)

此圖展示 Value Object 的三大核心特性和它在架構中的定位:不可變性確保安全共享、值相等決定比較邏輯、自我驗證在建構時就擋住非法值。Value Object 取代原始型別(Primitive Obsession),被 Entity 作為屬性包含。

何時用 Value Object,何時不用

| 場景 | 用 Value Object | 說明 | |------|----------------|------| | 金額計算 | 是 | Money 包含 amount + currency,防止不同幣別相加 | | 電子郵件 | 是 | Email 在建構時驗證格式,不合法直接拋例外 | | 地址 | 是 | Address 由街道+城市+郵遞區號組成,值相同就是同一個地址 | | 日期區間 | 是 | DateRange 包含起始和結束日期,可以驗證結束不早於起始 | | 只有一個 string 且無驗證規則 | 考慮不用 | 如果只是包裝 string 且沒有額外邏輯,過度包裝反而增加複雜度 | | 資料庫 ID | 通常不用 | 除非你需要強型別 ID(如 OrderId vs CustomerId 防止混用) |

實戰補充

💡資深開發者筆記

C# 9+ 的 record 語法天生就是為 Value Object 設計的:自動產生值相等、不可變、ToString()。可以把基底類別省掉,直接用 public record Money(decimal Amount, string Currency);

EF Core 存儲 Value Object 的方式:使用 OwnsOneComplexProperty 把 Value Object 的欄位嵌入 Entity 的表中,不要為 Value Object 建獨立的表。

一個實用的判斷法則:如果你發現兩個東西「內容一樣就算一樣」,它就是 Value Object;如果需要「追蹤它是哪一個」,它就是 Entity。

理解測驗

🤔 Value Object 和 Entity 最關鍵的區別是什麼?

🤔 為什麼 Value Object 必須是不可變的?

🤔 以下哪個最適合用 Value Object 表示?

重點整理

💡一句話記住

口訣:「沒身分證、不能改、值一樣就算同一個」—— Value Object 三大鐵律。

| 概念 | 說明 | |------|------| | 不可變性 | 建立後不能修改,要改就建新的 | | 值相等 | 所有屬性一樣就相等 | | 自我驗證 | 建構時檢查合法性 | | 核心好處 | 消除 Primitive Obsession,提升型別安全 | | 儲存方式 | 嵌入 Entity 的表,不建獨立表 |

你可能也想看

Entity(實體)Aggregate(聚合)

按 ← → 鍵切換課程