4. 领域模型设计:实体、值对象、聚合在Go中的实现模式

领域模型是业务逻辑的核心载体。在DDD中,实体(Entity)、值对象(Value Object)和聚合(Aggregate)构成了领域模型的基本构造块。Go语言以其简洁的语法和显式的设计哲学,为这三种模式提供了独特的实现路径。

4.1 实体(Entity):身份标识的守护者

实体拥有唯一标识,其状态可以随时间变化,但身份保持不变。在Go中,实体通常是一个结构体,其标识字段(如ID)是区分不同实例的唯一依据。

核心原则:两个实体即使所有属性值都相同,只要ID不同,就是不同的对象。实体的==比较应基于ID,而非内存地址。

4.1.1 实体实现模式

// User 实体:通过 UserID 唯一标识
type User struct {
    ID        UserID    // 值对象作为ID
    Name      string
    Email     string
    CreatedAt time.Time
}

// UserID 值对象,封装ID的生成与验证逻辑
type UserID struct {
    value string
}

func NewUserID() UserID {
    return UserID{value: uuid.New().String()}
}

func (id UserID) String() string {
    return id.value
}

// 实体比较:基于ID
func (u User) Equals(other User) bool {
    return u.ID.String() == other.ID.String()
}
特性 实体 值对象
身份标识 必须有唯一ID 无ID,由属性值定义
可变性 可变(属性可更新) 不可变(替换而非修改)
相等性 基于ID相等 基于所有属性相等
生命周期 有独立生命周期 无生命周期,依附于实体
Go实现 struct + ID字段 struct + 不可变方法

4.2 值对象(Value Object):不可变的属性集合

值对象描述是什么,而不是是谁。它们是不可变的,通过属性值来定义相等性。在Go中,值对象通常通过私有字段和构造函数来保证不可变性。

4.2.1 值对象实现模式

// Address 值对象:不可变,通过属性值定义相等性
type Address struct {
    street   string
    city     string
    zipCode  string
}

func NewAddress(street, city, zipCode string) (Address, error) {
    if street == "" || city == "" || zipCode == "" {
        return Address{}, errors.New("address fields cannot be empty")
    }
    return Address{
        street:   street,
        city:     city,
        zipCode:  zipCode,
    }, nil
}

// 值对象相等性:基于所有字段
func (a Address) Equals(other Address) bool {
    return a.street == other.street &&
           a.city == other.city &&
           a.zipCode == other.zipCode
}

// 值对象不可变:返回新实例而非修改原实例
func (a Address) WithStreet(newStreet string) Address {
    return Address{
        street:  newStreet,
        city:    a.city,
        zipCode: a.zipCode,
    }
}
Go实践建议:值对象的字段使用小写(包私有),通过构造函数NewXXX创建实例。避免暴露指针接收器方法,防止外部修改。

4.3 聚合(Aggregate):一致性边界

聚合是一组相关对象的集合,作为一个数据修改单元。每个聚合有一个聚合根(Aggregate Root),外部只能通过聚合根访问聚合内的对象。聚合保证了业务不变量(Invariants)在每次操作后都保持一致。

4.3.1 聚合设计原则

  • 单一聚合根:每个聚合只有一个根实体,外部通过根访问内部对象。
  • 一致性边界:聚合内的所有对象在同一个事务中保持一致。
  • 小聚合优先:聚合应尽量小,只包含必须保持一致的实体和值对象。
  • 通过ID引用:聚合之间通过ID引用,而非直接持有对象引用。

4.3.2 聚合实现模式

// Order 聚合根:订单聚合
type Order struct {
    ID          OrderID
    customerID  CustomerID      // 通过ID引用其他聚合
    items       []OrderItem     // 聚合内的实体
    totalAmount Money           // 值对象
    status      OrderStatus
}

// OrderItem 聚合内的实体:属于Order聚合
type OrderItem struct {
    ProductID ProductID
    Quantity  int
    UnitPrice Money
}

// 聚合根方法:保证业务不变量
func (o *Order) AddItem(productID ProductID, quantity int, unitPrice Money) error {
    if o.status != OrderStatusPending {
        return errors.New("cannot add items to non-pending order")
    }
    if quantity <= 0 {
        return errors.New("quantity must be positive")
    }
    
    o.items = append(o.items, OrderItem{
        ProductID: productID,
        Quantity:  quantity,
        UnitPrice: unitPrice,
    })
    o.recalculateTotal()
    return nil
}

// 私有方法:保证内部一致性
func (o *Order) recalculateTotal() {
    total := Money{Amount: 0, Currency: "CNY"}
    for _, item := range o.items {
        total = total.Add(item.UnitPrice.Multiply(item.Quantity))
    }
    o.totalAmount = total
}
常见陷阱:不要将聚合设计得过大。一个常见的反模式是将“用户”及其所有“订单”、“地址”、“支付方式”放在同一个聚合中。这会导致并发性能下降和事务冲突。正确的做法是:每个聚合只包含必须一起修改的对象。

4.4 三种模式的协作关系

下图展示了实体、值对象和聚合在领域模型中的协作关系:

领域模型构造块协作关系 聚合边界(Aggregate Boundary) Order(聚合根 - 实体) ID: OrderID | Status: OrderStatus OrderItem(实体) ProductID | Quantity | UnitPrice Money(值对象) Amount | Currency Customer(外部聚合) 通过 CustomerID 引用 包含 包含 通过ID引用 实体(Entity) 值对象(Value Object) 外部聚合 通过ID引用

4.5 Go实现中的关键决策

决策点 推荐方案 反模式
ID类型 使用值对象封装ID(如UserID),而非原始类型 直接使用string或int64作为ID,导致类型混淆
值对象创建 使用构造函数NewXXX,返回(Value, error) 直接暴露结构体字段,允许无效状态
聚合根方法 使用指针接收器(o *Order)修改聚合状态 使用值接收器导致修改不生效
跨聚合引用 通过ID引用,如customerID CustomerID 直接持有*Customer指针,破坏聚合边界
事务边界 一个事务只修改一个聚合 在一个事务中修改多个聚合

4.6 实战案例:订单领域模型

以下是一个完整的订单领域模型示例,展示了实体、值对象和聚合的协作:

// ========== 值对象 ==========
type Money struct {
    amount   int64  // 以分为单位,避免浮点数精度问题
    currency string
}

func NewMoney(amount int64, currency string) (Money, error) {
    if amount < 0 {
        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")
    }
    return Money{amount: m.amount + other.amount, currency: m.currency}, nil
}

// ========== 实体 ==========
type OrderItem struct {
    productID ProductID
    quantity  int
    unitPrice Money
}

func NewOrderItem(productID ProductID, quantity int, unitPrice Money) (OrderItem, error) {
    if quantity <= 0 {
        return OrderItem{}, errors.New("quantity must be positive")
    }
    return OrderItem{
        productID: productID,
        quantity:  quantity,
        unitPrice: unitPrice,
    }, nil
}

// ========== 聚合根 ==========
type Order struct {
    id         OrderID
    customerID CustomerID
    items      []OrderItem
    total      Money
    status     OrderStatus
}

func NewOrder(id OrderID, customerID CustomerID) *Order {
    return &Order{
        id:         id,
        customerID: customerID,
        items:      []OrderItem{},
        status:     OrderStatusPending,
    }
}

func (o *Order) AddItem(item OrderItem) error {
    if o.status != OrderStatusPending {
        return errors.New("order is not pending")
    }
    o.items = append(o.items, item)
    o.recalculateTotal()
    return nil
}

func (o *Order) Submit() error {
    if o.status != OrderStatusPending {
        return errors.New("order already submitted")
    }
    if len(o.items) == 0 {
        return errors.New("cannot submit empty order")
    }
    o.status = OrderStatusSubmitted
    return nil
}

func (o *Order) recalculateTotal() {
    total, _ := NewMoney(0, "CNY")
    for _, item := range o.items {
        itemTotal, _ := NewMoney(
            item.unitPrice.amount * int64(item.quantity),
            item.unitPrice.currency,
        )
        total, _ = total.Add(itemTotal)
    }
    o.total = total
}
Go特有优势:Go的显式错误处理天然适合DDD的业务不变量检查。每个修改聚合的方法都可以返回error,将业务规则验证与领域逻辑紧密结合,避免抛出异常带来的控制流混乱。

4.7 总结

  • 实体:用struct + ID字段实现,基于ID比较相等性。
  • 值对象:用struct + 私有字段 + 构造函数实现,保证不可变。
  • 聚合:用struct + 指针接收器方法实现,通过聚合根保证一致性边界。
  • 跨聚合引用:始终使用ID引用,避免直接持有对象指针。
  • 事务边界:一个事务只修改一个聚合,保持设计简洁。