4领域驱动设计

值对象

描述特征的对象

值对象不可变性相等性

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第4章 值对象 - 描述特征的对象 (Value Objects - Descriptive Characteristics)

一、导读

4.1 本章学习目标

通过本章的学习,读者将能够:

理解值对象(Value Object)在 DDD 中的定义与核心特征

掌握值对象与实体的本质区别

学会设计不可变的值对象

理解值对象相等性判断的语义

掌握值对象的组合与验证技巧

能够在实际项目中正确识别和设计值对象

理解值对象在性能优化中的作用

掌握处理值对象集合的最佳实践

4.2 为什么本章如此重要

值对象是 DDD 中另一种基本的对象类型,它与实体共同构成了领域模型的基础。在许多业务系统中,存在大量用于描述特征、度量或概念的对象,如地址、金额、日期范围、颜色等。这些对象没有唯一标识,它们通过属性值来区分。这就是值对象的典型应用场景。

正确理解和使用值对象对于构建简洁、清晰的领域模型至关重要。值对象可以帮助我们将复杂的概念封装成有意义的单元,使代码更具表达力。同时,值对象的不可变性使得它们天生线程安全,易于测试和推理。

在实际开发中,值对象常常被忽视或误用。很多开发者习惯于使用基本类型(如 String、Integer)来表示业务概念,导致业务规则散落在各处,难以维护。例如,用 String 表示邮箱地址,邮箱格式的验证逻辑就会出现在多个地方;用两个独立的 Date 表示时间范围,范围的有效性检查就需要重复编写。将这些概念建模为值对象,可以将相关的验证和行为集中在一起。

值对象还与函数式编程的理念高度契合。不可变性、基于值的相等性、无副作用的操作,这些都是函数式编程的核心原则。掌握值对象的设计不仅有助于 DDD 实践,也有助于培养函数式思维。

此外,值对象在性能优化方面也发挥着重要作用。由于值对象是不可变的且基于值比较,它们可以安全地缓存和复用,减少内存分配和垃圾回收的压力。在高并发场景下,值对象的不可变性避免了同步开销。

4.3 核心问题引导

在学习本章之前,请思考以下问题:

什么是值对象?它与普通的数据结构有什么区别?

值对象和实体的根本区别是什么?如何判断一个概念应该是值对象而不是实体?

为什么值对象应该是不可变的?不可变性带来了哪些好处?

值对象的相等性应该如何判断?与实体的相等性有什么不同?

当值对象的验证失败时,应该如何处理?

值对象可以包含行为吗?如果可以,应该包含哪些类型的行为?

如何处理值对象的集合?在集合中使用值对象需要注意什么?

值对象是否应该持久化到数据库?如果需要,应该如何处理?


二、核心概念详解

4.1 值对象的定义与本质

值对象(Value Object)是领域模型中一种通过属性值来定义的对象,它没有概念上的唯一标识。两个值对象如果所有属性都相同,则被认为是相等的,可以互相替换。

值对象的本质在于它描述的是"什么"(what),而不是"哪个"(which)。当我们关心一个对象的特征或度量时,我们使用值对象;当我们关心一个对象的身份和连续性时,我们使用实体。

以货币金额为例,当我们说"100 元"时,我们关心的是金额的值,而不是这个金额对象的身份。任何代表"100 元"的对象都是等价的,可以互相替换。这就是值对象的典型特征。

再以地址为例,如果我们只是用地址来描述一个人的居住地,那么地址就是值对象。两个完全相同的地址(街道、城市、邮编等都相同)是没有区别的,可以互相替换。但如果我们需要跟踪某个特定地址的历史变化(如邮寄记录),那么地址可能就需要被建模为实体。

值对象的关键特征包括:

无标识:值对象没有唯一标识。它们的相等性完全由属性值决定。

不可变性:值对象一旦创建就不能修改。如果需要改变值,必须创建新的值对象实例。

基于值的相等性:两个值对象如果所有相关属性都相同,则被认为相等。

可替换性:相等的值对象可以互相替换,不会影响业务逻辑。

自验证:值对象在创建时应该验证其有效性,确保始终处于合法状态。

行为丰富:值对象不仅仅是数据容器,它们可以包含与自身相关的业务行为。

4.2 值对象的核心特征

不可变性(Immutability)

不可变性是值对象最重要的特征。一旦值对象被创建,它的状态就不能被改变。这带来了多方面的好处:

首先,不可变性使得值对象天生线程安全。在多线程环境中,多个线程可以安全地共享同一个值对象实例,无需担心并发修改问题。

其次,不可变性简化了推理。由于值对象不会变化,我们可以确信它的值在任何时候都是创建时设定的值。这减少了 bug 的可能性。

第三,不可变性支持安全的共享和缓存。相等的值对象可以复用同一个实例,节省内存。

实现不可变性的关键是:将所有字段设为 final(Java)或使用不可变数据结构(Python 的 frozen dataclass),不提供修改状态的方法。如果需要"修改"值对象,应该创建一个新的实例。

基于值的相等性(Value-based Equality)

值对象的相等性判断基于其所有相关属性。如果两个值对象的属性值都相同,它们就是相等的。这与实体形成鲜明对比,实体的相等性基于唯一标识。

在 Java 中,需要重写 equals() 和 hashCode() 方法,基于所有相关属性进行比较。在 Python 中,可以使用 @dataclass(frozen=True) 自动生成正确的相等性实现。

基于值的相等性使得值对象可以在集合中正确使用,如 HashSet、HashMap 等。

自包含的验证(Self-validation)

值对象应该在创建时验证自己的有效性。构造函数或工厂方法是进行验证的理想位置。如果输入无效,应该抛出异常,阻止非法值对象的创建。

例如,邮箱地址值对象应该在创建时验证格式是否正确;金额值对象应该验证不能为负数。通过将验证逻辑放在值对象内部,可以确保值对象始终处于合法状态。

丰富的行为(Rich Behavior)

值对象不仅仅是数据容器,它们可以包含与自身相关的业务行为。这些行为通常是对值对象本身的转换或计算,不涉及外部依赖。

例如,金额值对象可以有加法、减法、乘法等方法;日期范围值对象可以有判断是否包含某日期的方法;地址值对象可以有格式化输出的方法。

将行为放在值对象内部有几个好处:一是行为与数据放在一起,符合面向对象的原则;二是行为可以复用,避免重复代码;三是行为可以利用值对象的不变量,简化实现。

4.3 值对象与实体的区别

值对象和实体是 DDD 中两种最基本的对象类型,正确区分它们对于领域建模至关重要。

特征实体值对象
标识有唯一标识无标识,通过值区分
可变性通常可变不可变
相等性基于标识基于属性值
生命周期有明确生命周期无生命周期,瞬时存在
共享可以被共享引用通常复制传递
复杂度通常较复杂通常较简单
关注点"哪个"对象"什么"值

判断一个概念应该是实体还是值对象,可以问以下问题:

  • 我们是否关心这个对象的身份?如果是,可能是实体。
  • 两个属性完全相同的对象是否代表不同的事物?如果是,可能是实体。
  • 这个对象是否需要跟踪变化历史?如果是,可能是实体。
  • 这个对象是否可以被另一个具有相同属性的对象替换而不影响业务?如果是,可能是值对象。
  • 这个对象是否只是一个度量或描述?如果是,可能是值对象。

需要注意的是,同一个概念在不同场景下可能是实体也可能是值对象。例如,地址在大多数情况下是值对象,但如果需要跟踪地址的变化历史(如物流追踪),地址可能需要被建模为实体。上下文决定了对象的类型。

4.4 值对象的设计原则

设计良好的值对象应该遵循以下原则:

保持小型和专注

值对象应该只包含相关的属性,不要试图在一个值对象中塞入太多内容。小型的值对象更容易理解、测试和复用。

例如,将地址拆分为街道地址、城市、州、邮编等单独的字段,或者进一步拆分为更小的值对象如 ZipCode、PhoneNumber。

确保不可变性

这是值对象的核心要求。所有字段应该是 final 的(Java)或等效的不可变形式。不提供任何修改状态的方法。如果需要改变值,创建新的实例。

实现正确的相等性

重写 equals() 和 hashCode() 方法,基于所有相关属性进行比较。确保相等的对象有相同的哈希码,这对于在哈希集合中正确使用值对象至关重要。

提供有意义的行为

值对象应该包含与其职责相关的行为。常见的行为类型包括:

  • 转换:将一个值对象转换为另一个形式,如 Money.toString()。
  • 计算:基于值对象进行计算,如 Money.add()、DateRange.duration()。
  • 验证:检查值对象是否满足某些条件,如 Email.isValid()。
  • 比较:比较两个值对象,如 Money.compareTo()。

处理空值

值对象不应该允许 null 值。对于可选的值对象,可以使用 Optional 类型(Java)或 None(Python)。对于值对象内部的字段,也应该避免 null,可以使用空字符串、零值或专门的 Null 对象。

考虑性能

虽然值对象通常是小型的,但在高频使用的场景下,频繁创建值对象可能带来性能开销。可以考虑使用工厂方法来缓存常用实例,如 Money.zero()、Color.white()。

4.5 值对象的组合

值对象可以组合使用,形成更复杂的结构。这种组合是构建丰富领域模型的重要手段。

例如,一个 Address 值对象可以由 StreetAddress、City、State、ZipCode 等值对象组成:

java
public class Address {
    private final StreetAddress street;
    private final City city;
    private final State state;
    private final ZipCode zipCode;
    
    public Address(StreetAddress street, City city, State state, ZipCode zipCode) {
        this.street = street;
        this.city = city;
        this.state = state;
        this.zipCode = zipCode;
    }
}

这种组合有几个好处:

首先,每个组成部分都可以有自己的验证逻辑。ZipCode 可以验证格式,City 可以验证名称合法性等。

其次,组合提高了代码的可读性和表达力。address.getCity().getName() 比 address.getCityName() 更清晰地表达了结构。

第三,小组件可以复用。ZipCode 可以用在 Address 中,也可以用在其他需要邮编的地方。

在组合值对象时,注意保持合理的粒度。过度拆分可能导致复杂性增加,而拆分不足则失去了组合的优势。

4.6 值对象的持久化

值对象是否需要持久化取决于它们的使用场景。如果值对象是实体的属性,那么当实体被持久化时,值对象的数据也需要被保存。

在关系数据库中,值对象通常有以下几种持久化策略:

嵌入同一表

将值对象的属性作为列存储在实体所在的表中。例如,Customer 表的列包括 street、city、state、zip_code,这些列共同组成 Address 值对象。

这种方式简单直接,适合值对象与实体紧密绑定的场景。缺点是值对象不能独立查询。

单独表

为值对象创建单独的表,通过外键与实体关联。这种方式适合值对象可能被多个实体共享的场景。

序列化存储

将值对象序列化为 JSON 或其他格式,存储在单个列中。这种方式灵活但牺牲了查询能力。

在 JPA 中,可以使用 @Embeddable 和 @Embedded 注解来映射值对象。在 MyBatis 中,可以使用 TypeHandler 进行自定义映射。

重要的是,持久化细节不应该影响值对象的设计。值对象应该保持对持久化的无知,持久化策略是基础设施层的关注点。


三、程序员的视角

4.1 在实际项目中应用值对象

作为程序员,在日常工作中识别和设计值对象可以从以下几个方面入手:

首先,警惕基本类型迷恋(Primitive Obsession)。如果发现代码中大量使用 String、Integer 等基本类型来表示业务概念,如邮箱、电话、金额、日期等,考虑将它们重构为值对象。

其次,寻找重复的验证逻辑。如果同一种验证在多处出现,如邮箱格式验证、电话号码格式验证,将这些概念建模为值对象,将验证逻辑集中在构造函数中。

第三,注意成对出现的字段。如果发现某些字段经常一起出现,如 startDate 和 endDate、latitude 和 longitude、min 和 max,考虑将它们组合成值对象。

第四,利用语言特性简化实现。Java 16+ 的 record、Python 的 frozen dataclass、Kotlin 的 data class 都可以方便地创建不可变值对象。

第五,为值对象添加有意义的行为。不要只把值对象当作数据容器,思考它可以提供哪些有用的操作。例如,金额可以加减乘除,日期范围可以判断重叠,坐标可以计算距离。

第六,在测试中充分利用值对象的特性。由于值对象是不可变的且基于值比较,它们很容易进行单元测试。编写全面的测试覆盖各种边界情况。

4.2 常见陷阱与误区

在使用值对象时,常见的陷阱包括:

陷阱一:忘记实现不可变性。提供了 setter 方法或可变的集合字段,导致值对象实际上可变。这会破坏值对象的许多优势。

陷阱二:相等性实现错误。没有重写 equals() 和 hashCode(),或者只基于部分属性比较。这会导致值对象在集合中行为异常。

陷阱三:允许 null 值。值对象字段为 null,导致运行时 NPE。应该在构造函数中验证并拒绝 null。

陷阱四:值对象过大。试图在一个值对象中包含太多内容,使其变得复杂难用。应该保持值对象小型和专注。

陷阱五:忽视行为。只把值对象当作数据容器,不添加任何行为。这浪费了值对象的潜力。

陷阱六:在需要实体的场景使用值对象。例如,需要跟踪变化历史的对象被建模为值对象,导致无法正确表达业务需求。

陷阱七:可变集合作为值对象字段。即使值对象本身不可变,如果它包含可变集合的引用,仍然可能被间接修改。应该使用不可变集合或防御性复制。

4.3 与其他模式的协作

值对象与其他 DDD 模式紧密协作:

与实体协作:值对象常作为实体的属性。实体持有值对象的引用,通过值对象来表达复杂的概念。

与聚合协作:值对象可以在聚合内部自由传递,因为它们是不可变的,不会破坏聚合的一致性边界。

与领域服务协作:领域服务可以使用值对象作为参数和返回值。值对象使得服务的接口更加清晰和类型安全。

与仓储协作:仓储负责将值对象与实体一起持久化。值对象本身不需要仓储,因为它们没有独立的生命周期。

与工厂协作:工厂可以创建复杂的值对象,特别是当创建逻辑涉及验证或计算时。


四、代码实战

4.1 Java 示例:金额值对象

java
/**
 * 金额值对象
 * 不可变,基于值比较,包含丰富的行为
 */
public final class Money {
    private final BigDecimal amount;
    private final Currency currency;
    
    private static final int DEFAULT_SCALE = 2;
    private static final RoundingMode DEFAULT_ROUNDING = RoundingMode.HALF_UP;
    
    /**
     * 构造金额
     */
    public Money(BigDecimal amount, Currency currency) {
        if (amount == null) {
            throw new IllegalArgumentException("Amount cannot be null");
        }
        if (currency == null) {
            throw new IllegalArgumentException("Currency cannot be null");
        }
        this.amount = amount.setScale(DEFAULT_SCALE, DEFAULT_ROUNDING);
        this.currency = currency;
    }
    
    public Money(double amount, Currency currency) {
        this(BigDecimal.valueOf(amount), currency);
    }
    
    public Money(String amount, Currency currency) {
        this(new BigDecimal(amount), currency);
    }
    
    /**
     * 零金额
     */
    public static Money zero(Currency currency) {
        return new Money(BigDecimal.ZERO, currency);
    }
    
    /**
     * 加法
     */
    public Money add(Money other) {
        checkSameCurrency(other);
        return new Money(this.amount.add(other.amount), this.currency);
    }
    
    /**
     * 减法
     */
    public Money subtract(Money other) {
        checkSameCurrency(other);
        return new Money(this.amount.subtract(other.amount), this.currency);
    }
    
    /**
     * 乘法
     */
    public Money multiply(int multiplier) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(multiplier)), this.currency);
    }
    
    public Money multiply(BigDecimal multiplier) {
        return new Money(this.amount.multiply(multiplier).setScale(DEFAULT_SCALE, DEFAULT_ROUNDING), this.currency);
    }
    
    /**
     * 除法
     */
    public Money divide(int divisor) {
        if (divisor == 0) {
            throw new ArithmeticException("Division by zero");
        }
        return new Money(this.amount.divide(BigDecimal.valueOf(divisor), DEFAULT_SCALE, DEFAULT_ROUNDING), this.currency);
    }
    
    /**
     * 是否为零
     */
    public boolean isZero() {
        return this.amount.compareTo(BigDecimal.ZERO) == 0;
    }
    
    /**
     * 是否为正
     */
    public boolean isPositive() {
        return this.amount.compareTo(BigDecimal.ZERO) > 0;
    }
    
    /**
     * 是否为负
     */
    public boolean isNegative() {
        return this.amount.compareTo(BigDecimal.ZERO) < 0;
    }
    
    /**
     * 取绝对值
     */
    public Money abs() {
        return new Money(this.amount.abs(), this.currency);
    }
    
    /**
     * 比较
     */
    public int compareTo(Money other) {
        checkSameCurrency(other);
        return this.amount.compareTo(other.amount);
    }
    
    public boolean isGreaterThan(Money other) {
        return compareTo(other) > 0;
    }
    
    public boolean isLessThan(Money other) {
        return compareTo(other) < 0;
    }
    
    private void checkSameCurrency(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException(
                "Cannot operate on different currencies: " + this.currency + " vs " + other.currency
            );
        }
    }
    
    /**
     * 格式化输出
     */
    public String format() {
        NumberFormat formatter = NumberFormat.getCurrencyInstance(Locale.US);
        formatter.setCurrency(currency);
        return formatter.format(amount);
    }
    
    // 基于值的相等性
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Money money = (Money) o;
        return amount.compareTo(money.amount) == 0 && currency.equals(money.currency);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(amount, currency);
    }
    
    @Override
    public String toString() {
        return amount + " " + currency.getCurrencyCode();
    }
    
    // Getters
    public BigDecimal getAmount() { return amount; }
    public Currency getCurrency() { return currency; }
}

4.2 Python 示例:地址值对象

python
from dataclasses import dataclass
import re


@dataclass(frozen=True)
class ZipCode:
    """值对象:邮政编码"""
    value: str
    
    def __post_init__(self):
        # 美国邮编格式:12345 或 12345-6789
        if not re.match(r'^\d{5}(-\d{4})?$', self.value):
            raise ValueError(f"Invalid ZIP code format: {self.value}")
    
    def __str__(self):
        return self.value


@dataclass(frozen=True)
class City:
    """值对象:城市"""
    name: str
    
    def __post_init__(self):
        if not self.name or len(self.name.strip()) == 0:
            raise ValueError("City name cannot be empty")
        if len(self.name) > 100:
            raise ValueError("City name too long")
    
    def __str__(self):
        return self.name


@dataclass(frozen=True)
class State:
    """值对象:州/省"""
    code: str  # 如 CA, NY
    name: str
    
    def __post_init__(self):
        if len(self.code) != 2:
            raise ValueError("State code must be 2 characters")
        if not self.code.isupper():
            raise ValueError("State code must be uppercase")
    
    def __str__(self):
        return f"{self.code} ({self.name})"


@dataclass(frozen=True)
class StreetAddress:
    """值对象:街道地址"""
    line1: str
    line2: str = ""  # 可选的第二行
    
    def __post_init__(self):
        if not self.line1 or len(self.line1.strip()) == 0:
            raise ValueError("Street address line 1 cannot be empty")
    
    def __str__(self):
        if self.line2:
            return f"{self.line1}, {self.line2}"
        return self.line1


@dataclass(frozen=True)
class Address:
    """值对象:完整地址
    
    由多个更小的值对象组合而成
    """
    street: StreetAddress
    city: City
    state: State
    zip_code: ZipCode
    
    def format_single_line(self) -> str:
        """单行格式"""
        parts = [
            str(self.street),
            str(self.city),
            str(self.state.code),
            str(self.zip_code)
        ]
        return ", ".join(parts)
    
    def format_multi_line(self) -> str:
        """多行格式"""
        lines = [str(self.street)]
        city_state_zip = f"{self.city}, {self.state.code} {self.zip_code}"
        lines.append(city_state_zip)
        return "\n".join(lines)
    
    def __str__(self):
        return self.format_single_line()


# 使用示例
def address_example():
    """演示地址值对象的使用"""
    # 创建地址
    address = Address(
        street=StreetAddress("123 Main St", "Apt 4B"),
        city=City("Springfield"),
        state=State("IL", "Illinois"),
        zip_code=ZipCode("62701")
    )
    
    print("Single line:", address.format_single_line())
    print("\nMulti line:")
    print(address.format_multi_line())
    
    # 尝试创建无效地址(应该失败)
    try:
        invalid_address = Address(
            street=StreetAddress(""),  # 空街道地址
            city=City("Test"),
            state=State("CA", "California"),
            zip_code=ZipCode("12345")
        )
    except ValueError as e:
        print(f"\nValidation error: {e}")
    
    # 值对象相等性
    address2 = Address(
        street=StreetAddress("123 Main St", "Apt 4B"),
        city=City("Springfield"),
        state=State("IL", "Illinois"),
        zip_code=ZipCode("62701")
    )
    
    print(f"\nAddresses equal: {address == address2}")  # True


@dataclass(frozen=True)
class DateRange:
    """值对象:日期范围"""
    start_date: date
    end_date: date
    
    def __post_init__(self):
        if self.end_date < self.start_date:
            raise ValueError("End date cannot be before start date")
    
    def contains(self, d: date) -> bool:
        """判断日期是否在范围内"""
        return self.start_date <= d <= self.end_date
    
    def overlaps(self, other: 'DateRange') -> bool:
        """判断是否与另一个日期范围重叠"""
        return self.start_date <= other.end_date and other.start_date <= self.end_date
    
    def duration_days(self) -> int:
        """计算天数"""
        return (self.end_date - self.start_date).days
    
    def __str__(self):
        return f"{self.start_date} to {self.end_date}"

4.3 组合值对象示例:地理坐标

java
/**
 * 纬度值对象
 */
public final class Latitude {
    private final double value;
    
    public Latitude(double value) {
        if (value < -90 || value > 90) {
            throw new IllegalArgumentException("Latitude must be between -90 and 90");
        }
        this.value = value;
    }
    
    public double getValue() { return value; }
    
    public boolean isNorthern() {
        return value > 0;
    }
    
    public boolean isSouthern() {
        return value < 0;
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Latitude latitude = (Latitude) o;
        return Double.compare(value, latitude.value) == 0;
    }
    
    @Override
    public int hashCode() {
        return Double.hashCode(value);
    }
}

/**
 * 经度值对象
 */
public final class Longitude {
    private final double value;
    
    public Longitude(double value) {
        if (value < -180 || value > 180) {
            throw new IllegalArgumentException("Longitude must be between -180 and 180");
        }
        this.value = value;
    }
    
    public double getValue() { return value; }
    
    public boolean isEastern() {
        return value > 0;
    }
    
    public boolean isWestern() {
        return value < 0;
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Longitude longitude = (Longitude) o;
        return Double.compare(value, longitude.value) == 0;
    }
    
    @Override
    public int hashCode() {
        return Double.hashCode(value);
    }
}

/**
 * 地理坐标值对象
 * 由纬度和经度组合而成
 */
public final class GeoCoordinate {
    private final Latitude latitude;
    private final Longitude longitude;
    
    public GeoCoordinate(Latitude latitude, Longitude longitude) {
        this.latitude = latitude;
        this.longitude = longitude;
    }
    
    public GeoCoordinate(double lat, double lon) {
        this(new Latitude(lat), new Longitude(lon));
    }
    
    /**
     * 计算与另一个坐标的距离(Haversine 公式)
     */
    public double distanceTo(GeoCoordinate other) {
        double earthRadiusKm = 6371.0;
        
        double lat1Rad = Math.toRadians(this.latitude.getValue());
        double lat2Rad = Math.toRadians(other.latitude.getValue());
        double deltaLatRad = Math.toRadians(other.latitude.getValue() - this.latitude.getValue());
        double deltaLonRad = Math.toRadians(other.longitude.getValue() - this.longitude.getValue());
        
        double a = Math.sin(deltaLatRad / 2) * Math.sin(deltaLatRad / 2) +
                   Math.cos(lat1Rad) * Math.cos(lat2Rad) *
                   Math.sin(deltaLonRad / 2) * Math.sin(deltaLonRad / 2);
        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
        
        return earthRadiusKm * c;
    }
    
    public Latitude getLatitude() { return latitude; }
    public Longitude getLongitude() { return longitude; }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        GeoCoordinate that = (GeoCoordinate) o;
        return latitude.equals(that.latitude) && longitude.equals(that.longitude);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(latitude, longitude);
    }
    
    @Override
    public String toString() {
        return String.format("(%.6f, %.6f)", latitude.getValue(), longitude.getValue());
    }
}

五、深入思考

5.1 历史背景

值对象的概念源于面向对象设计的早期。在 Design Patterns(GoF)一书中,值对象被描述为一种常见的设计模式。然而,当时的值对象主要被视为数据传输的载体,缺乏丰富的业务含义。

Eric Evans 在《领域驱动设计》中重新定义了值对象的角色,将其提升为领域模型的一等公民。他强调值对象不仅仅是数据容器,而是可以包含行为的完整对象。这一观点与 Martin Fowler 在《Analysis Patterns》中的思想相呼应。

随着函数式编程的复兴,值对象的重要性进一步提升。不可变性、基于值的相等性、无副作用的操作,这些值对象的特征与函数式编程的核心理念高度一致。在现代软件设计中,值对象成为了连接面向对象和函数式编程的桥梁。

5.2 前沿方向

值对象的实践正在向以下几个方向发展:

首先是记录类型(Record Types)的普及。Java 16 引入的 record、Kotlin 的 data class、Python 的 dataclass 等语言特性,使得创建不可变值对象变得更加简单。这些记录类型自动生成了构造函数、相等性判断、toString 等样板代码。

其次是值对象在事件溯源中的应用。在事件溯源模式中,事件通常被建模为值对象,因为事件是不可变的、基于值比较的。值对象的特性完美契合事件的需求。

第三是与领域事件的结合。值对象可以作为领域事件的载荷,因为它们的不可变性保证了事件发布后不会被修改。

第四是在分布式系统中的应用。值对象的不可变性使得它们在分布式环境中更安全地传递,无需担心并发修改问题。

5.3 延伸阅读

Eric Evans, 《领域驱动设计》第5章 - 原始论述

Vaughn Vernon, 《实现领域驱动设计》第6章 - 实用指南

Martin Fowler, 《Value Object》- Patterns of Enterprise Application Architecture

Joshua Bloch, 《Effective Java》第10条 - 遵守通用约定


六、实验笔记

实验一:重构基本类型迷恋

实验目的:通过重构练习,将基本类型替换为值对象。

实验步骤:

选择一个现有项目或代码片段,找出其中使用基本类型表示业务概念的地方,如 String 表示邮箱、Integer 表示数量等。

为每个概念设计对应的值对象,包括属性和验证逻辑。

重构代码,将基本类型替换为值对象。

更新相关的测试,确保功能正常。

比较重构前后的代码,分析改进之处。

预期收获:体验值对象带来的代码质量提升,掌握重构技巧。

实验二:设计一组金融值对象

实验目的:练习值对象的组合和行为设计。

实验步骤:

设计以下值对象:Currency(货币)、Money(金额)、ExchangeRate(汇率)、Percentage(百分比)。

为每个值对象添加合适的行为,如金额的加减乘除、汇率转换、百分比计算等。

实现值对象之间的协作,如使用汇率将一种货币的金额转换为另一种货币。

编写全面的单元测试。

将这些值对象应用到一个简单的场景中,如货币兑换计算器。

预期收获:掌握值对象的组合设计和行为封装。


七、本章小结

本章详细介绍了 DDD 中的值对象模式。我们首先定义了值对象的本质:通过属性值来定义、没有唯一标识的对象。然后,我们探讨了值对象的核心特征:不可变性、基于值的相等性、自验证和丰富的行为。

值对象与实体的区别是本章的重点内容。我们通过多个维度对比了这两种对象类型,并提供了判断标准。正确区分值对象和实体对于构建清晰的领域模型至关重要。

值对象的设计原则包括:保持小型和专注、确保不可变性、实现正确的相等性、提供有意义的行为、处理空值、考虑性能。遵循这些原则可以设计出高质量的值对象。

通过代码实战,我们展示了如何实现金额、地址、地理坐标等值对象。这些示例体现了值对象的最佳实践:不可变性、基于值的相等性、丰富的行为、组合使用。

最后,我们回顾了值对象的历史背景,展望了前沿方向,并提供了两个实验任务帮助读者实践所学内容。

值对象是领域模型的重要组成部分。正确使用值对象可以使代码更具表达力、更易于维护、更安全。希望读者能够在实际项目中应用本章的知识,设计出优雅的值对象。


八、习题

概念理解题:用自己的话解释什么是值对象,它的核心特征是什么?

辨析题:以下概念哪些应该是值对象,哪些应该是实体?说明理由。

- 颜色

- 用户会话

- 温度

- 发票

- 尺寸(长宽高)

- IP 地址

设计题:设计一个 EmailAddress 值对象,包括验证逻辑和常用行为。写出完整的 Java 或 Python 代码。

代码分析题:以下 Java 代码有什么问题?如何修正?

java
public class Money {
    private double amount;
    private String currency;
    
    public void setAmount(double amount) {
        this.amount = amount;
    }
    
    public void setCurrency(String currency) {
        this.currency = currency;
    }
}

思考题:为什么值对象应该是不可变的?不可变性带来了哪些具体好处?

实践题:在你当前参与的项目中,找出三个可以用值对象替换的基本类型,设计对应的值对象并说明理由。

设计题:设计一个 TimeRange 值对象,表示时间范围。包括开始时间、结束时间,以及判断是否包含某个时间点、计算持续时间、判断是否与另一个时间范围重叠等行为。

论述题:值对象和 DTO(Data Transfer Object)有什么区别?它们可以互换使用吗?

开放题:在高并发场景下,值对象的不可变性如何帮助提高性能?请举例说明。

综合题:为一个电商系统设计一组值对象,至少包括:价格、折扣、税率、配送地址。展示它们如何协作完成订单价格计算。

4/12值对象
0%