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 中实现不可变值对象的完整模式:
- 私有字段:所有字段首字母小写,外部无法直接访问。
- New 构造函数:唯一创建入口,在构造函数中校验所有不变量。
- 只读访问器:通过方法返回字段副本,不暴露内部引用。
- 无 Setter:不提供任何修改内部状态的方法。
- Equals 方法:基于所有字段的值比较。
- 返回新实例:任何业务操作都返回新的值对象。
以下 SVG 展示了值对象在聚合中的不可变流转模式:
4.5 实体与值对象的协作关系
在微服务领域模型中,实体与值对象通常以聚合(Aggregate)的形式协作:
- 实体作为聚合根:对外暴露业务方法,保证聚合内不变量。
- 值对象作为属性:被实体引用,提供类型安全与业务语义。
- 值对象可以包含值对象:例如
OrderItem包含Money作为单价。 - 实体不应引用另一个实体的内部值对象:通过 ID 引用其他聚合根。
实战经验:在 Go 微服务中,将值对象定义在独立的
vo 包中,实体定义在 entity 包中。值对象包不依赖实体包,实体包依赖值对象包。这种单向依赖保证了领域层的整洁性。
4.6 选择决策树
当你在建模时,可以使用以下决策树快速判断:
是否拥有唯一标识?
├── 是 → 实体(Entity)
│ ├── 需要追踪生命周期? → 实体
│ ├── 需要独立持久化? → 实体
│ └── 两个相同属性的对象是否不同? → 实体
└── 否 → 值对象(Value Object)
├── 所有属性决定相等性? → 值对象
├── 需要不可变性? → 值对象
└── 无独立生命周期? → 值对象