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

在领域驱动设计中,实体(Entity)与值对象(Value Object)是构成领域模型的两类核心元素。许多架构退化源于将值对象误用为实体,或反之。本章从概念、标识、生命周期与 Go 实现四个维度,厘清二者的本质差异,并给出不可变值对象的设计模式。

4.1 核心区别:标识 vs 属性

实体与值对象最根本的区别在于标识(Identity)的持有方式:

维度 实体(Entity) 值对象(Value Object)
标识 拥有唯一标识(ID),标识决定相等性 无标识,所有属性共同决定相等性
可变性 允许状态变化,标识不变 不可变,状态变化即生成新对象
生命周期 有独立生命周期,可被追踪 无生命周期,随所属实体创建/销毁
相等性判断 通过 ID 比较 通过所有属性值比较
典型例子 用户、订单、商品 金额、地址、颜色、坐标
架构决策提示:当你在建模时犹豫“这个对象该不该有 ID”,请反问自己:“如果两个对象的所有属性都相同,它们是否应该被视为同一个事物?” 如果答案是“是”,则它是值对象;如果答案是“否”,则它是实体。

4.2 实体:标识驱动

实体在 Go 中的实现核心是显式标识字段与标识相等性方法。以下是一个典型的订单实体:

// Order 是领域实体,由 OrderID 唯一标识
type Order struct {
    ID        OrderID
    CustomerID CustomerID
    Items     []OrderItem
    Status    OrderStatus
    CreatedAt time.Time
    UpdatedAt time.Time
}

// OrderID 是值对象,用于标识订单
type OrderID struct {
    value string
}

func NewOrderID(value string) (OrderID, error) {
    if value == "" {
        return OrderID{}, errors.New("order ID must not be empty")
    }
    return OrderID{value: value}, nil
}

func (id OrderID) String() string {
    return id.value
}

// SameIdentityAs 比较两个实体是否相同
func (o *Order) SameIdentityAs(other *Order) bool {
    return o.ID == other.ID
}

实体允许状态变更,但标识必须保持不变:

// 实体方法可以修改内部状态
func (o *Order) ChangeStatus(newStatus OrderStatus) error {
    if !o.Status.CanTransitionTo(newStatus) {
        return fmt.Errorf("cannot transition from %s to %s", o.Status, newStatus)
    }
    o.Status = newStatus
    o.UpdatedAt = time.Now()
    return nil
}

4.3 值对象:属性驱动与不可变性

值对象的核心特征是不可变性(Immutability)和值相等性(Value Equality)。在 Go 中,值对象通常通过以下模式实现:

// Money 是不可变值对象
type Money struct {
    amount   decimal.Decimal
    currency string
}

// NewMoney 是唯一构造入口,保证不变量
func NewMoney(amount decimal.Decimal, currency string) (Money, error) {
    if amount.IsNegative() {
        return Money{}, errors.New("amount must not be negative")
    }
    if currency == "" {
        return Money{}, errors.New("currency must not 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
}

// 禁止暴露可变的内部状态
func (m Money) Amount() decimal.Decimal {
    return m.amount
}

func (m Money) Currency() string {
    return m.currency
}
常见陷阱:不要为值对象暴露指针接收者的 Setter 方法。如果客户端调用 money.SetAmount(100) 修改了内部字段,就破坏了不可变性。所有“变更”必须通过返回新实例的方法实现。

4.4 不可变值对象设计模式

在微服务架构中,值对象经常跨服务边界传递。不可变性保证了并发安全与数据一致性。以下是 Go 中实现不可变值对象的完整模式:

  1. 私有字段:所有字段首字母小写,外部无法直接访问。
  2. New 构造函数:唯一创建入口,在构造函数中校验所有不变量。
  3. 只读访问器:通过方法返回字段副本,不暴露内部引用。
  4. 无 Setter:不提供任何修改内部状态的方法。
  5. Equals 方法:基于所有字段的值比较。
  6. 返回新实例:任何业务操作都返回新的值对象。

以下 SVG 展示了值对象在聚合中的不可变流转模式:

不可变值对象在聚合中的流转模式 Order(实体) ID: OrderID Status: OrderStatus Items: []OrderItem Money(值对象) amount: decimal currency: string Address(值对象) city: string zipCode: string OrderItem(值对象) productID: string quantity: int 不可变操作模式 1. NewMoney(amount, currency) 2. money.Add(otherMoney) 3. money.Equals(otherMoney) 4. 所有操作返回新实例 值对象被实体引用,但值对象本身无标识、不可变、无生命周期

4.5 实体与值对象的协作关系

在微服务领域模型中,实体与值对象通常以聚合(Aggregate)的形式协作:

  • 实体作为聚合根:对外暴露业务方法,保证聚合内不变量。
  • 值对象作为属性:被实体引用,提供类型安全与业务语义。
  • 值对象可以包含值对象:例如 OrderItem 包含 Money 作为单价。
  • 实体不应引用另一个实体的内部值对象:通过 ID 引用其他聚合根。
实战经验:在 Go 微服务中,将值对象定义在独立的 vo 包中,实体定义在 entity 包中。值对象包不依赖实体包,实体包依赖值对象包。这种单向依赖保证了领域层的整洁性。

4.6 选择决策树

当你在建模时,可以使用以下决策树快速判断:

是否拥有唯一标识?
├── 是 → 实体(Entity)
│   ├── 需要追踪生命周期? → 实体
│   ├── 需要独立持久化? → 实体
│   └── 两个相同属性的对象是否不同? → 实体
└── 否 → 值对象(Value Object)
    ├── 所有属性决定相等性? → 值对象
    ├── 需要不可变性? → 值对象
    └── 无独立生命周期? → 值对象