4. 实体与值对象:实体(Entity)与值对象(Value Object)的区别、Go结构体实现、不变性设计

4.1 核心概念:实体与值对象的本质区别

在领域驱动设计中,实体(Entity)和值对象(Value Object)是构成领域模型的两大基本元素。它们的核心区别在于:实体拥有唯一标识(Identity),而值对象由属性值(Attributes)定义。

一个实体即使所有属性都发生变化,只要标识不变,它仍然是同一个对象。例如,一个用户修改了姓名和邮箱,但用户ID不变,他仍然是同一个人。而值对象一旦属性值改变,就变成了一个不同的对象。例如,“北京市朝阳区”和“北京市海淀区”是两个不同的地址值对象。

核心判断法则: 问自己“这个对象需要被追踪和区分吗?”如果答案是“是”,则设计为实体;如果答案是“否”,且对象只关心其属性值的组合,则设计为值对象。

4.2 实体与值对象的对比表格

对比维度 实体(Entity) 值对象(Value Object)
标识 拥有唯一标识(ID) 无标识,由属性值定义
可变性 通常可变(Mutable) 必须不可变(Immutable)
相等性判断 通过标识判断 通过所有属性值判断
生命周期 有独立生命周期 无生命周期,可被替换
副作用 允许修改内部状态 禁止修改,只能创建新实例
典型例子 用户、订单、产品 金额、地址、颜色、时间段

4.3 Go结构体实现实体

在Go中,实体通常使用struct实现,并显式包含一个唯一标识字段。实体的方法可以修改其内部状态,但应通过领域方法(Domain Method)而非直接暴露字段。

// 实体:用户
type User struct {
    ID        UserID    // 唯一标识
    Name      string
    Email     string
    CreatedAt time.Time
}

// 实体的领域方法:允许修改状态
func (u *User) ChangeEmail(newEmail string) error {
    if !isValidEmail(newEmail) {
        return errors.New("invalid email format")
    }
    u.Email = newEmail
    return nil
}

// 实体的相等性判断:基于标识
func (u *User) Equals(other *User) bool {
    return u.ID == other.ID
}
Go实践提示: 实体方法使用指针接收者(*User)以允许修改状态。标识字段建议使用自定义类型(如UserID),而非直接使用string或int64,以增强类型安全。

4.4 Go结构体实现值对象与不变性设计

值对象在Go中的实现需要遵循不变性(Immutability)原则:一旦创建,其内部状态不可改变。任何“修改”操作都应返回一个新的值对象实例。

// 值对象:金额
type Money struct {
    amount   decimal.Decimal
    currency string
}

// 构造函数:确保创建有效实例
func NewMoney(amount decimal.Decimal, currency string) (Money, error) {
    if amount.IsNegative() {
        return Money{}, errors.New("amount cannot be negative")
    }
    if currency == "" {
        return Money{}, errors.New("currency cannot be empty")
    }
    return Money{amount: amount, currency: currency}, nil
}

// 值对象的“修改”操作:返回新实例
func (m Money) Add(other Money) (Money, error) {
    if m.currency != other.currency {
        return Money{}, errors.New("currency mismatch")
    }
    newAmount := m.amount.Add(other.amount)
    return Money{amount: newAmount, currency: m.currency}, nil
}

// 值对象的相等性判断:基于所有属性
func (m Money) Equals(other Money) bool {
    return m.amount.Equal(other.amount) && m.currency == other.currency
}
不变性设计要点:
  • 字段使用小写首字母(私有),禁止外部直接修改
  • 不提供 Setter 方法
  • 所有“修改”方法返回新实例
  • 构造函数(NewXXX)进行有效性校验
  • 值对象方法使用值接收者(Money)而非指针接收者

4.5 实体与值对象的关系:内联SVG图

下图展示了实体与值对象在领域模型中的协作关系:实体持有值对象作为属性,值对象为实体提供不可变的属性描述。

实体与值对象的协作关系 实体:订单 (Order) • orderID: OrderID (标识) • customerID: CustomerID • items: []OrderItem • shippingAddress: Address • totalAmount: Money 值对象:金额 (Money) • amount: decimal.Decimal • currency: string • 不可变,无标识 值对象:地址 (Address) • street: string • city: string • zipCode: string • 不可变,无标识 实体持有值对象 订单实体包含多个值对象: • 金额(描述总价) • 地址(描述收货位置)

4.6 实体与值对象的选择决策流程

在实际建模中,如何判断一个概念应该设计为实体还是值对象?以下决策流程可以帮助你做出正确选择:

  1. 是否需要唯一标识? 如果“是”,则设计为实体;如果“否”,进入下一步。
  2. 是否关心生命周期? 如果“是”,则设计为实体;如果“否”,进入下一步。
  3. 属性值改变后是否变成不同对象? 如果“是”,则设计为值对象;如果“否”,则设计为实体。
  4. 是否会被多个实体共享? 如果“是”,优先考虑值对象(不可变,安全共享)。
实战经验: 在Go项目中,常见的错误是将所有概念都设计为实体。实际上,值对象应该比实体更多。一个良好的领域模型通常包含约30%的实体和70%的值对象。值对象的不可变性天然支持并发安全,非常适合Go的并发编程模型。

4.7 值对象的不变性设计:内联SVG图

下图展示了值对象不变性设计的核心机制:所有“修改”操作都返回新实例,原始实例保持不变。

值对象不变性设计模式 原始 Money 实例 amount: 100.00 currency: USD 状态:不可变 Add(50.00 USD) 返回新实例 新 Money 实例 amount: 150.00 currency: USD 状态:不可变 不变性设计的关键规则 1. 所有字段私有且只读(无Setter) 2. 任何“修改”操作都返回新的值对象实例 3. 原始实例在操作后保持不变,可安全共享

4.8 常见陷阱与最佳实践

常见陷阱:
  • 陷阱1: 将值对象设计为可变结构体,导致共享引用被意外修改。
  • 陷阱2: 实体中使用指针接收值对象,导致多个实体共享同一个值对象实例。
  • 陷阱3: 值对象中嵌入实体标识,破坏了值对象的纯粹性。
  • 陷阱4: 在Go中使用map或slice作为值对象的字段,这些引用类型需要额外注意不可变性。
Go最佳实践:
  • 值对象优先使用struct值类型,避免使用指针
  • 值对象实现String()方法便于调试
  • 值对象实现Equals()方法用于比较
  • 实体使用go generate或代码生成工具自动生成标识
  • 使用go vet和staticcheck检查值对象的可变性违规

4.9 本章小结

实体与值对象的正确区分是DDD建模的基础。实体通过唯一标识追踪业务对象的生命周期,值对象通过不可变的属性组合描述业务概念。在Go中,实体使用指针接收者实现可变方法,值对象使用值接收者并返回新实例来保证不变性。掌握这两者的区别和实现技巧,是构建健壮领域模型的第一步。