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 三种模式的协作关系
下图展示了实体、值对象和聚合在领域模型中的协作关系:
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引用,避免直接持有对象指针。
- 事务边界:一个事务只修改一个聚合,保持设计简洁。