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图
下图展示了实体与值对象在领域模型中的协作关系:实体持有值对象作为属性,值对象为实体提供不可变的属性描述。
4.6 实体与值对象的选择决策流程
在实际建模中,如何判断一个概念应该设计为实体还是值对象?以下决策流程可以帮助你做出正确选择:
- 是否需要唯一标识? 如果“是”,则设计为实体;如果“否”,进入下一步。
- 是否关心生命周期? 如果“是”,则设计为实体;如果“否”,进入下一步。
- 属性值改变后是否变成不同对象? 如果“是”,则设计为值对象;如果“否”,则设计为实体。
- 是否会被多个实体共享? 如果“是”,优先考虑值对象(不可变,安全共享)。
实战经验: 在Go项目中,常见的错误是将所有概念都设计为实体。实际上,值对象应该比实体更多。一个良好的领域模型通常包含约30%的实体和70%的值对象。值对象的不可变性天然支持并发安全,非常适合Go的并发编程模型。
4.7 值对象的不变性设计:内联SVG图
下图展示了值对象不变性设计的核心机制:所有“修改”操作都返回新实例,原始实例保持不变。
4.8 常见陷阱与最佳实践
常见陷阱:
- 陷阱1: 将值对象设计为可变结构体,导致共享引用被意外修改。
- 陷阱2: 实体中使用指针接收值对象,导致多个实体共享同一个值对象实例。
- 陷阱3: 值对象中嵌入实体标识,破坏了值对象的纯粹性。
- 陷阱4: 在Go中使用
map或slice作为值对象的字段,这些引用类型需要额外注意不可变性。
Go最佳实践:
- 值对象优先使用
struct值类型,避免使用指针 - 值对象实现
String()方法便于调试 - 值对象实现
Equals()方法用于比较 - 实体使用
go generate或代码生成工具自动生成标识 - 使用
go vet和staticcheck检查值对象的可变性违规
4.9 本章小结
实体与值对象的正确区分是DDD建模的基础。实体通过唯一标识追踪业务对象的生命周期,值对象通过不可变的属性组合描述业务概念。在Go中,实体使用指针接收者实现可变方法,值对象使用值接收者并返回新实例来保证不变性。掌握这两者的区别和实现技巧,是构建健壮领域模型的第一步。