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 实体与值对象的协作关系

下图展示了实体与值对象在领域模型中的典型协作模式:

实体与值对象协作关系图 实体:User id: UserID (UUID) name: string status: UserStatus 实体:Order id: OrderID (ULID) totalAmount: Money status: OrderStatus 值对象:Address province / city / district 不可变,无 ID 值对象:Money amount: decimal currency: string 持有 持有 创建 实体通过 ID 标识身份,值对象通过属性值定义内容 值对象不可变,变更时返回新实例

2.6 实战决策矩阵

在建模过程中,使用以下矩阵快速判断一个概念应设计为实体还是值对象:

判断问题 是 → 实体 否 → 值对象
这个对象需要被单独追踪吗? 需要,如“订单 12345” 不需要,如“金额 100 元”
两个对象属性相同,是否代表同一个? 不一定,ID 不同则不同 是,属性相同即相等
对象状态会变化吗? 会,如“订单状态流转” 不会,替换而非修改
对象有独立生命周期吗? 有,可独立创建和销毁 无,依附于实体存在
最佳实践总结:
  • 优先将“需要身份追踪”的概念建模为实体,将“描述性、度量性”的概念建模为值对象。
  • 值对象能显著减少实体间的复杂关联,提升模型的内聚性。
  • 在 Go 中,值对象通过不可变设计和 Equals() 方法实现语义完整性。