战术设计:实体与值对象的Go实现、聚合根的设计原则、仓储模式

战术设计是领域驱动设计(DDD)从战略蓝图落地到代码实现的关键桥梁。本章将聚焦于DDD中最核心的三个战术构件:实体(Entity)、值对象(Value Object)以及聚合根(Aggregate Root),并结合Go语言的特性,探讨如何通过仓储模式(Repository Pattern)实现领域对象的持久化隔离。理解并正确运用这些模式,是构建高内聚、低耦合微服务的基础。

1. 实体与值对象:领域模型的基本单元

在DDD中,区分实体和值对象是建模的第一步。实体拥有唯一的身份标识,其状态会随时间变化;而值对象则描述事物的属性,没有独立身份,通过其所有属性的值来定义。

特性 实体 (Entity) 值对象 (Value Object)
身份标识 必须有唯一ID(如UUID、自增ID) 无独立ID,由属性值定义
可变性 可变(Mutable),状态可更新 不可变(Immutable),替换而非修改
相等性 通过ID判断相等 通过所有属性值判断相等
生命周期 有独立生命周期,可被追踪 无生命周期,依附于实体
Go实现要点 使用struct + 指针接收者方法 使用struct + 值接收者方法,返回新实例

1.1 实体的Go实现

在Go中,实体通常通过一个包含唯一标识符的struct来表示。所有修改实体状态的方法都应使用指针接收者,以确保对同一实例的修改生效。

// User 实体,拥有唯一ID
type User struct {
    ID        string    // 唯一标识
    Name      string
    Email     string
    CreatedAt time.Time
    UpdatedAt time.Time
}

// ChangeEmail 修改邮箱,使用指针接收者
func (u *User) ChangeEmail(newEmail string) error {
    if !isValidEmail(newEmail) {
        return errors.New("invalid email format")
    }
    u.Email = newEmail
    u.UpdatedAt = time.Now()
    return nil
}

// NewUser 工厂方法,确保创建时生成ID
func NewUser(name, email string) (*User, error) {
    if name == "" || email == "" {
        return nil, errors.New("name and email are required")
    }
    return &User{
        ID:        uuid.New().String(),
        Name:      name,
        Email:     email,
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }, nil
}
实践要点:实体的ID生成应放在领域层(如工厂方法或构造函数中),而非由基础设施层(如数据库)生成。这保证了领域模型在持久化之前就拥有完整的身份。

1.2 值对象的Go实现

值对象是不可变的。在Go中,我们通过不暴露内部字段、使用值接收者并返回新实例来实现不可变性。值对象通常用于表示地址、货币、日期范围等。

// Address 值对象,不可变
type Address struct {
    street   string
    city     string
    zipCode  string
    country  string
}

// NewAddress 构造函数,返回指针或值均可,但推荐返回值以强调不可变性
func NewAddress(street, city, zipCode, country string) (Address, error) {
    if street == "" || city == "" {
        return Address{}, errors.New("street and city are required")
    }
    return Address{
        street:  street,
        city:    city,
        zipCode: zipCode,
        country: country,
    }, nil
}

// WithStreet 返回一个新的Address,仅修改街道
func (a Address) WithStreet(newStreet string) Address {
    return Address{
        street:   newStreet,
        city:     a.city,
        zipCode:  a.zipCode,
        country:  a.country,
    }
}

// Equals 值对象相等性比较
func (a Address) Equals(other Address) bool {
    return a.street == other.street &&
        a.city == other.city &&
        a.zipCode == other.zipCode &&
        a.country == other.country
}
常见误区:不要为值对象设置Setter方法或暴露可变字段。值对象的替换逻辑应通过“WithXxx”方法返回新实例,而不是修改原有实例。这能有效避免因共享引用导致的意外状态污染。

2. 聚合根的设计原则

聚合(Aggregate)是一组相关对象的集合,聚合根(Aggregate Root)是这个集合的入口和唯一外部访问点。聚合根保证了内部对象的一致性和不变性。

2.1 聚合根的核心原则

  • 一致性边界:聚合内部的所有变更必须通过聚合根进行,确保业务规则始终得到执行。
  • 原子性操作:对聚合的每次操作都应是一个完整的事务边界,要么全部成功,要么全部失败。
  • 引用外部ID:聚合根之间通过全局唯一标识符(ID)引用,而不是直接持有对象引用,以降低耦合。
  • 小聚合原则:聚合应尽量小,只包含必须保持一致性的对象。过大的聚合会导致性能问题和并发冲突。

2.2 聚合根的Go实现示例

以下是一个Order聚合根的实现,它包含OrderItem实体和Money值对象。

// Money 值对象
type Money struct {
    amount   float64
    currency string
}

func NewMoney(amount float64, currency string) Money {
    return Money{amount: amount, currency: currency}
}

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
}

// OrderItem 实体,属于Order聚合
type OrderItem struct {
    ProductID string
    Quantity  int
    UnitPrice Money
}

// Order 聚合根
type Order struct {
    ID         string
    CustomerID string
    Items      []OrderItem
    Total      Money
    Status     string
}

// NewOrder 创建订单聚合根
func NewOrder(id, customerID string) (*Order, error) {
    if id == "" || customerID == "" {
        return nil, errors.New("id and customerID are required")
    }
    return &Order{
        ID:         id,
        CustomerID: customerID,
        Items:      []OrderItem{},
        Total:      NewMoney(0, "CNY"),
        Status:     "pending",
    }, nil
}

// AddItem 通过聚合根添加商品,保证总价计算正确
func (o *Order) AddItem(productID string, quantity int, unitPrice Money) error {
    if o.Status != "pending" {
        return errors.New("can only add items to pending order")
    }
    item := OrderItem{
        ProductID: productID,
        Quantity:  quantity,
        UnitPrice: unitPrice,
    }
    o.Items = append(o.Items, item)
    // 重新计算总价,保证一致性
    newTotal, _ := o.Total.Add(NewMoney(float64(quantity)*unitPrice.amount, unitPrice.currency))
    o.Total = newTotal
    return nil
}
设计检查清单:
  • 聚合根是否拥有全局唯一ID?
  • 外部对象是否只能通过聚合根ID引用该聚合?
  • 聚合内部的所有业务规则是否都在聚合根的方法中执行?
  • 聚合是否足够小,只包含必须保持一致性的对象?

3. 仓储模式:领域与基础设施的桥梁

仓储(Repository)模式为领域模型提供了对象集合式的访问接口,它将领域层的持久化需求与基础设施层的具体实现(如数据库、缓存)解耦。在DDD中,仓储只针对聚合根设计。

3.1 仓储接口定义(领域层)

仓储接口属于领域层,它定义了如何获取和持久化聚合根,而不关心具体的技术实现。

// OrderRepository 仓储接口,定义在领域层
type OrderRepository interface {
    Save(order *Order) error
    FindByID(id string) (*Order, error)
    FindByCustomerID(customerID string) ([]*Order, error)
    Delete(id string) error
}

3.2 仓储实现(基础设施层)

具体的实现(如基于PostgreSQL或MongoDB)放在基础设施层。领域层通过依赖注入使用仓储接口,完全不依赖具体实现。

// PostgresOrderRepository 基础设施层的实现
type PostgresOrderRepository struct {
    db *sql.DB
}

func NewPostgresOrderRepository(db *sql.DB) *PostgresOrderRepository {
    return &PostgresOrderRepository{db: db}
}

func (r *PostgresOrderRepository) Save(order *Order) error {
    // 使用事务保证聚合内多个表的原子性操作
    tx, err := r.db.Begin()
    if err != nil {
        return err
    }
    defer tx.Rollback()

    // 保存订单主表
    _, err = tx.Exec(`INSERT INTO orders (id, customer_id, total_amount, total_currency, status) 
                      VALUES ($1, $2, $3, $4, $5) 
                      ON CONFLICT (id) DO UPDATE SET ...`, 
                      order.ID, order.CustomerID, order.Total.amount, order.Total.currency, order.Status)
    if err != nil {
        return err
    }

    // 保存订单项
    for _, item := range order.Items {
        _, err = tx.Exec(`INSERT INTO order_items (order_id, product_id, quantity, unit_price_amount, unit_price_currency) 
                          VALUES ($1, $2, $3, $4, $5)`, 
                          order.ID, item.ProductID, item.Quantity, item.UnitPrice.amount, item.UnitPrice.currency)
        if err != nil {
            return err
        }
    }

    return tx.Commit()
}

func (r *PostgresOrderRepository) FindByID(id string) (*Order, error) {
    // 实现查询并重建聚合根
    row := r.db.QueryRow(`SELECT id, customer_id, total_amount, total_currency, status FROM orders WHERE id = $1`, id)
    order := &Order{}
    err := row.Scan(&order.ID, &order.CustomerID, &order.Total.amount, &order.Total.currency, &order.Status)
    if err != nil {
        return nil, err
    }

    // 加载订单项
    rows, err := r.db.Query(`SELECT product_id, quantity, unit_price_amount, unit_price_currency FROM order_items WHERE order_id = $1`, id)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    for rows.Next() {
        var item OrderItem
        rows.Scan(&item.ProductID, &item.Quantity, &item.UnitPrice.amount, &item.UnitPrice.currency)
        order.Items = append(order.Items, item)
    }
    return order, nil
}
仓储 vs DAO:DAO(数据访问对象)通常是面向表的CRUD操作,而仓储是面向聚合根的集合操作。仓储的Save方法应保证整个聚合的持久化一致性,而不仅仅是单张表。

4. 战术设计全景图

下面的SVG图展示了实体、值对象、聚合根和仓储在DDD分层架构中的协作关系。

DDD战术设计协作关系图 DDD战术设计:实体、值对象、聚合根与仓储协作 领域层 (Domain Layer) 聚合根 (Order) 一致性边界 / 业务规则 实体 (OrderItem) 唯一ID / 可变状态 值对象 (Money) 不可变 / 属性相等 包含 引用 OrderRepository (接口) Save / FindByID / Delete 基础设施层 (Infrastructure Layer) PostgresOrderRepository SQL / 事务 / ORM PostgreSQL 持久化存储 依赖注入 接口 在领域层 定义

5. 战术设计决策矩阵

在实际建模过程中,快速判断一个概念应该设计为实体还是值对象,以及如何设计聚合根,可以参考以下决策矩阵:

判断维度 实体 值对象 聚合根
是否需要追踪变化? 是 否 是(作为聚合入口)
是否有独立生命周期? 是 否 是
是否会被其他对象共享? 通常不共享引用 可以安全共享 通过ID被外部引用
修改频率 高 低(替换而非修改) 中高
是否包含业务规则? 是 通常只包含自校验 是(核心业务规则)
持久化方式 独立表 嵌入或序列化 主表 + 子表

6. 总结与最佳实践

  • 优先使用值对象:在建模时,优先考虑使用值对象。只有当概念需要唯一身份和生命周期追踪时,才将其设计为实体。这能显著减少模型的复杂度。
  • 聚合根是事务边界:一个请求只应加载和修改一个聚合根实例。跨聚合的修改应通过领域事件(Domain Events)异步处理,而不是在同一个事务中。
  • 仓储只对聚合根:不要为每个实体都创建仓储。只有聚合根才拥有仓储,聚合内部的实体通过聚合根来访问和持久化。
  • 依赖抽象:应用层(Application Layer)只依赖领域层的仓储接口,而非具体实现。这为单元测试和更换持久化方案提供了极大的灵活性。
Go语言特别提示:Go的隐式接口实现机制使得仓储模式的实现非常自然。领域层定义接口,基础设施层实现接口,应用层通过构造函数注入具体实现。这种模式在Go的微服务生态中非常普遍,也是构建可测试、可维护代码的基石。