3. Go语言中的领域模型构建:使用struct定义实体与值对象

领域驱动设计(DDD)的核心在于将业务概念精确地映射为代码模型。在Go语言中,struct类型是构建领域模型的主要载体。本章将深入探讨如何利用Go的struct来区分并实现领域中的两大基本构造块:实体(Entity)与值对象(Value Object)。

3.1 实体与值对象的核心区别

在开始编码之前,必须清晰理解两者的本质差异。实体拥有唯一的身份标识,其状态可以随时间变化;而值对象描述事物的某种属性或度量,没有身份标识,且是不可变的。

特征 实体(Entity) 值对象(Value Object)
身份标识 有唯一ID(如UUID、业务主键) 无身份标识,由属性值定义
可变性 可变(状态可更新) 不可变(创建后不可修改)
相等性 通过ID判断相等 通过所有属性值判断相等
生命周期 有独立生命周期,可被追踪 无生命周期,可被自由替换
典型例子 用户、订单、银行账户 地址、金额、颜色、坐标
设计提示: 在建模初期,一个常见的错误是将所有业务概念都建模为实体。优先考虑值对象可以显著降低模型的复杂度,并减少因共享引用带来的副作用。

3.2 使用struct定义实体

实体的定义核心在于身份标识。在Go中,我们通常将ID字段作为结构体的第一个字段,并确保其在整个系统中唯一且不可变。

// 用户实体
type User struct {
    ID        string    // 唯一标识,例如UUID
    Name      string
    Email     string
    CreatedAt time.Time
    UpdatedAt time.Time
}

// 订单实体
type Order struct {
    ID          string
    CustomerID  string
    OrderItems  []OrderItem // 值对象集合
    TotalAmount Money       // 值对象
    Status      OrderStatus // 值对象
    CreatedAt   time.Time
}

// 实体相等性比较:仅比较ID
func (u User) Equals(other User) bool {
    return u.ID == other.ID
}
避坑指南: 不要将实体的ID字段暴露为int自增主键。在分布式或微服务环境下,自增ID无法保证全局唯一性。推荐使用string类型的UUID或Snowflake ID。

3.3 使用struct定义值对象

值对象的设计原则是不可变性和自完备性。在Go中,我们通过不暴露内部字段的修改方法,并在构造函数中完成所有校验来实现不可变。

// 金额值对象
type Money struct {
    amount   decimal.Decimal // 小写,包外不可直接访问
    currency string
}

// 构造函数:确保创建时数据有效
func NewMoney(amount decimal.Decimal, currency string) (Money, error) {
    if amount.IsNegative() {
        return Money{}, errors.New("金额不能为负")
    }
    if currency == "" {
        return Money{}, errors.New("货币类型不能为空")
    }
    return Money{amount: amount, currency: currency}, nil
}

// 值对象相等性比较:比较所有字段
func (m Money) Equals(other Money) bool {
    return m.amount.Equal(other.amount) && m.currency == other.currency
}

// 值对象行为:返回一个新对象,而非修改自身
func (m Money) Add(other Money) (Money, error) {
    if m.currency != other.currency {
        return Money{}, errors.New("货币类型不匹配")
    }
    newAmount := m.amount.Add(other.amount)
    return Money{amount: newAmount, currency: m.currency}, nil
}
关键实践: 值对象的方法应当返回新的值对象实例,而不是修改接收者。这符合函数式编程的不可变思想,使得值对象可以安全地在多个实体间共享。

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

在领域模型中,实体通常持有对值对象的引用。一个订单实体可以包含多个OrderItem值对象和一个Money值对象。这种组合关系使得模型既具备身份追踪能力,又拥有精确的属性描述。

实体与值对象协作关系图 实体:Order - ID: string (UUID) - CustomerID: string - Status: OrderStatus - Items: []OrderItem - Total: Money 值对象:Money - amount: decimal - currency: string + Add(Money) Money + Equals(Money) bool 值对象:OrderItem - ProductID: string - Quantity: int - UnitPrice: Money 持有 包含集合

3.5 值对象的不可变实现模式

在Go中实现不可变值对象,需要遵循以下模式:

  1. 私有字段:将结构体字段首字母小写,限制包外直接访问。
  2. 构造函数:提供NewXXX函数,在创建时完成所有校验和初始化。
  3. 只读访问器:通过方法返回字段值的副本,而非指针。
  4. 无修改器:不提供SetXXX方法,所有“修改”操作都返回新实例。
// 地址值对象 - 完整不可变实现
type Address struct {
    street   string
    city     string
    state    string
    zipCode  string
    country  string
}

func NewAddress(street, city, state, zipCode, country string) (Address, error) {
    if street == "" || city == "" {
        return Address{}, errors.New("街道和城市不能为空")
    }
    // 更多业务校验...
    return Address{
        street:  street,
        city:    city,
        state:   state,
        zipCode: zipCode,
        country: country,
    }, nil
}

// 只读访问器
func (a Address) Street() string  { return a.street }
func (a Address) City() string    { return a.city }
func (a Address) State() string   { return a.state }
func (a Address) ZipCode() string { return a.zipCode }
func (a Address) Country() string { return a.country }

// 修改操作返回新实例
func (a Address) ChangeZipCode(newZip string) (Address, error) {
    if len(newZip) != 5 {
        return Address{}, errors.New("邮编长度必须为5位")
    }
    return Address{
        street:  a.street,
        city:    a.city,
        state:   a.state,
        zipCode: newZip,
        country: a.country,
    }, nil
}

// 值相等性
func (a Address) Equals(other Address) bool {
    return a.street == other.street &&
        a.city == other.city &&
        a.state == other.state &&
        a.zipCode == other.zipCode &&
        a.country == other.country
}

3.6 实体与值对象的建模决策流程

在实际建模过程中,可以使用以下决策树来判断一个概念应该建模为实体还是值对象:

实体 vs 值对象 建模决策流程 业务概念 需要唯一身份标识吗? 实体(Entity) 值对象(Value Object) 实体特征 • 有ID,可被追踪 • 状态可变 • 通过ID比较相等 • 有独立生命周期 值对象特征 • 无ID,由值定义 • 不可变 • 通过所有属性比较 • 可被自由替换 是 否

3.7 实战建议与常见陷阱

  • 优先使用值对象:对于没有独立生命周期的概念(如电话号码、邮箱地址、坐标),优先建模为值对象,可以显著减少实体数量。
  • 避免贫血模型:不要在实体中只放getter/setter,应将业务行为(如ChangeEmail、ApplyDiscount)封装在实体内部。
  • 警惕共享引用:当多个实体共享同一个值对象时,确保该值对象是不可变的,否则一个实体的修改会意外影响其他实体。
  • 使用指针的时机:在Go中,实体通常使用指针(*User)以支持状态变更;值对象通常使用值(Money)以确保不可变语义。
总结: 在Go中构建领域模型时,struct是核心工具。通过严格区分实体(有ID、可变)和值对象(无ID、不可变),并遵循不可变模式实现值对象,可以构建出健壮、可维护且贴近业务语言的领域模型。这是后续实现领域事件、聚合和仓储的基础。