2. 实体(Entity)建模:实体的定义、唯一标识、Go中实体的结构体设计与值对象区分
2.1 实体的核心定义
在领域驱动设计中,实体(Entity) 是具有唯一标识且在其生命周期中状态可变的领域对象。实体的本质特征不是其属性值,而是其身份的连续性。即使两个实体的所有属性完全相同,只要标识不同,它们就是不同的对象。
关键认知: 实体通过
ID 而非属性值来区分。例如,两个“用户”都叫“张三”,年龄相同,但 UserID 不同,则代表两个不同的用户实体。
| 特征 | 说明 | 示例 |
|---|---|---|
| 唯一标识 | 每个实体拥有不可变的身份标识 | OrderID、UserID、ProductID |
| 可变状态 | 属性值可以随时间变化 | 订单状态从“待支付”变为“已支付” |
| 生命周期 | 从创建到销毁,身份保持不变 | 用户注册后,即使修改手机号,仍是同一用户 |
| 业务行为 | 封装与自身状态相关的业务逻辑 | Order.Cancel()、User.ChangePassword() |
2.2 唯一标识的设计策略
唯一标识是实体的基石。在 Go 语言中,常见的标识类型选择如下:
- UUID / ULID: 分布式系统首选,无需中心化生成,推荐使用
github.com/google/uuid或github.com/oklog/ulid/v2。 - 自增整数: 适合单体应用或内部系统,由数据库生成,但不利于分布式迁移。
- 业务主键: 如身份证号、车牌号,但业务规则可能变化,耦合度高。
- 组合标识: 如
TenantID + OrderNumber,用于多租户场景。
避坑指南: 不要将数据库自增 ID 直接暴露给外部系统作为实体标识。一旦涉及分库分表或数据迁移,自增 ID 会引发冲突。建议在应用层使用 UUID 作为对外标识,内部持久化时再映射。
2.3 Go 中实体的结构体设计
在 Go 中,实体通常定义为结构体,并将唯一标识作为不可导出的字段或通过接口约束。以下是标准设计模式:
// 定义标识类型
type UserID string
// 实体结构体
type User struct {
id UserID // 不可导出,防止外部直接修改
name string
email string
status UserStatus
version int // 乐观锁版本号
}
// 构造函数:确保实体创建时必带 ID
func NewUser(id UserID, name string, email string) (*User, error) {
if id == "" {
return nil, errors.New("user id must not be empty")
}
if name == "" {
return nil, errors.New("user name must not be empty")
}
return &User{
id: id,
name: name,
email: email,
status: UserStatusActive,
}, nil
}
// 只读访问器
func (u *User) ID() UserID { return u.id }
func (u *User) Name() string { return u.name }
func (u *User) Email() string { return u.email }
func (u *User) Status() UserStatus { return u.status }
// 业务行为:封装状态变更逻辑
func (u *User) ChangeEmail(newEmail string) error {
if !strings.Contains(newEmail, "@") {
return errors.New("invalid email format")
}
u.email = newEmail
return nil
}
func (u *User) Deactivate() {
u.status = UserStatusInactive
}
设计要点:
- 标识不可变性:
id字段不提供 Setter,只通过构造函数赋值。 - 行为封装: 所有状态变更必须通过实体方法完成,禁止外部直接修改字段。
- 版本控制:
version字段用于乐观锁,防止并发写覆盖。
2.4 实体与值对象的区分
值对象(Value Object)是 DDD 中另一个核心概念,与实体形成互补。区分两者的核心标准是:是否拥有唯一标识 以及 是否可变。
| 维度 | 实体(Entity) | 值对象(Value Object) |
|---|---|---|
| 唯一标识 | 有,身份独立 | 无,由属性值定义 |
| 可变性 | 可变(Mutable) | 不可变(Immutable) |
| 相等性 | 通过 ID 判断 | 通过所有属性值判断 |
| 生命周期 | 有独立生命周期 | 依附于实体,无独立生命周期 |
| 典型例子 | 用户、订单、产品 | 地址、金额、颜色、时间段 |
在 Go 中,值对象通常设计为不可变结构体,不暴露 Setter,且实现 Equals() 方法:
// 值对象:地址
type Address struct {
province string
city string
district string
detail string
}
// 构造函数:值对象必须通过构造函数完整创建
func NewAddress(province, city, district, detail string) Address {
return Address{
province: province,
city: city,
district: district,
detail: detail,
}
}
// 值对象相等性比较
func (a Address) Equals(other Address) bool {
return a.province == other.province &&
a.city == other.city &&
a.district == other.district &&
a.detail == other.detail
}
// 值对象不可变:不提供 Setter,需要变更时返回新实例
func (a Address) WithDetail(newDetail string) Address {
return Address{
province: a.province,
city: a.city,
district: a.district,
detail: newDetail,
}
}
2.5 实体与值对象的协作关系
下图展示了实体与值对象在领域模型中的典型协作模式:
2.6 实战决策矩阵
在建模过程中,使用以下矩阵快速判断一个概念应设计为实体还是值对象:
| 判断问题 | 是 → 实体 | 否 → 值对象 |
|---|---|---|
| 这个对象需要被单独追踪吗? | 需要,如“订单 12345” | 不需要,如“金额 100 元” |
| 两个对象属性相同,是否代表同一个? | 不一定,ID 不同则不同 | 是,属性相同即相等 |
| 对象状态会变化吗? | 会,如“订单状态流转” | 不会,替换而非修改 |
| 对象有独立生命周期吗? | 有,可独立创建和销毁 | 无,依附于实体存在 |
最佳实践总结:
- 优先将“需要身份追踪”的概念建模为实体,将“描述性、度量性”的概念建模为值对象。
- 值对象能显著减少实体间的复杂关联,提升模型的内聚性。
- 在 Go 中,值对象通过不可变设计和
Equals()方法实现语义完整性。