战术设计:实体、值对象、聚合与领域事件

战术设计(Tactical Design)只追求一件事:业务不变量(Invariant)在代码里有唯一的守卫点。下面所有判定标准都围绕这条展开。

1. 先判定,再建模

判定维度实体(Entity)值对象(Value Object)
相等性依据唯一标识(ID)全部属性相等即相等
可变性生命周期内可变不可变,改就换新对象
典型例子订单、账户、支付单金额、地址、时间区间
一句话判定属性全变仍是「同一个东西」属性变了就是「另一个东西」

判定顺序很重要:先问「它需要身份吗」——给「金额」加自增主键,就是典型的过度实体化。

type Money struct { // 值对象:私有字段 + 构造函数校验 + 只读方法,没有 setter
    amount   int64 // 单位:分,避免浮点误差
    currency string
}
func NewMoney(amount int64, currency string) (Money, error) { // 校验集中在一处
    if amount < 0 || currency == "" {
        return Money{}, errors.New("金额或币种非法")
    }
    return Money{amount: amount, currency: currency}, nil
}
func (m Money) Add(o Money) (Money, error) {
    if m.currency != o.currency { // 规则收进类型,比散落在十处业务代码可靠
        return Money{}, errors.New("币种不一致,禁止直接相加")
    }
    return Money{amount: m.amount + o.amount, currency: m.currency}, nil
}

2. 聚合(Aggregate):一致性边界,不是对象树

聚合是一组必须保持一致的对象,聚合根(Aggregate Root)是外部唯一入口,同时承担不变量守卫与事务边界。

订单聚合 (Aggregate: Order)
├── Order        聚合根,外部只能拿到它,内部集合不可见
│   └── items[]  内部实体 OrderItem
└── 其他聚合只存 ID:customerID / productID(下单快照价格,不联表修改)
判据结论
不变式必须在同一事务内始终成立放进同一个聚合
只要求最终一致(可延迟、可重试)拆成不同聚合
两侧会被独立并发修改、互不约束拆开,避免锁竞争与写冲突
一边只是另一边的查询维度不进聚合,用查询模型或索引解决

配套规则:一个事务只改一个聚合;聚合间只存 ID 引用;聚合要小,宁可多拆——大聚合的代价是长事务、锁争用和爆炸半径。

3. 不变式写在聚合根里

// ✅ 状态改变只能走行为方法,不变式在聚合内闭环
func (o *Order) AddItem(sku SKUID, price Money, qty int) error {
    if o.status != StatusDraft {
        return errors.New("已提交的订单不允许改明细")
    }
    if qty <= 0 || len(o.items) >= maxItems {
        return errors.New("订单项非法或超出上限")
    }
    o.items = append(o.items, OrderItem{SKU: sku, Price: price, Qty: qty})
    o.total = o.total.Add(price.Mul(qty)) // 总额始终由聚合自己维护
    return nil
}
// ❌ 贫血模型:导出字段谁都能改,校验散落在 service、handler、定时任务里
type Order struct{ ID, Status string; Items []OrderItem; Total int64 }

贫血模型的坏处不是「不够 OO」,而是规则没有唯一归属:校验、状态流转、总额计算散落各处,改一处漏两处,问题往往只在生产数据里暴露。

4. 领域事件(Domain Event)

领域事件是「本上下文内已经发生的业务事实」:命名用过去式(OrderSubmitted、StockReserved),不能是技术动作(updateOrder、stockChanged)。

type OrderSubmitted struct{ OrderNo OrderID; Amount Money }

// 聚合内只「收集」事件,不在这里发消息
func (o *Order) Submit() error {
    if len(o.items) == 0 {
        return errors.New("空订单不能提交")
    }
    o.status = StatusSubmitted
    o.events = append(o.events, OrderSubmitted{OrderNo: o.id, Amount: o.total})
    return nil
}
// 由应用服务在事务提交后取走并发布
func (o *Order) PullEvents() []any { ev := o.events; o.events = nil; return ev }

发布时机是关键:提交前发消息,回滚后下游收到的是假事实;只提交不发布则可能丢事件,所以先落库、提交后发布(必要时配本地事件表)。另外区分:领域事件是进程内、本上下文的语言;集成事件跨上下文,必须经过翻译,不能把领域结构体直接序列化出去。

5. 领域服务与应用服务分工

维度应用服务(Application Service)领域服务(Domain Service)
职责编排用例:取聚合、调领域方法、提交事务、发事件承载不属于单个实体/值对象的领域规则
业务规则原则上不含判断规则纯领域规则、无状态
依赖仓储、事务、鉴权、DTO 转换只依赖领域模型(及少量抽象)
例子OrderAppService.Submit(cmd)PricingService.Quote(items, policy)

提领域服务的门槛要高:只有当规则跨多个聚合,或依赖外部策略(如促销计算)而放进实体会污染模型时才提;否则就是给贫血模型找台阶。

6. 仓储(Repository)接口放哪一层

结论:接口定义在领域层(使用方一侧),实现在基础设施层,即依赖倒置(Dependency Inversion)。

// domain/order/repository.go —— 领域层只声明「需要什么」
type Repository interface {
    ByID(ctx context.Context, id OrderID) (*Order, error)
    Save(ctx context.Context, o *Order) error // 语义是保存聚合,而非 insert
}
// infra/persistence/order_repo.go —— 实现层允许出现 ORM 类型
type orderRepo struct{ db *gorm.DB }

var _ order.Repository = (*orderRepo)(nil) // 编译期断言,防止接口漂移

三条红线:不暴露 Where/Offset 之类查询构造器(否则领域层被迫懂存储);不返回 ORM 实体;不返回分页包装。列表、报表、多表联查走独立的查询服务(Query Service),不要塞进仓储。

7. 落地检查项

  • 每条不变式能指出唯一守卫点(哪个聚合根方法或值对象构造函数)。
  • 聚合字段对外不可直接改;跨聚合只保存 ID,不做跨聚合联表写。
  • 一个应用服务方法只提交一个聚合;需要改多个时用领域事件 + 最终一致。
  • 领域事件是过去式的业务事实,发布时机在事务提交之后。

8. 常见误区

  • 聚合照数据库外键设计:外键是数据关系不是一致性边界,会把聚合撑得巨大。
  • 值对象当可变结构体用:一旦有 setter,币种、精度这类不变量立刻失效。
  • 每个实体配一个仓储:仓储的对象是聚合根,内部实体不该有独立仓储。
  • 领域事件直接当集成事件跨服务发:跨上下文必须翻译,内部模型不是公开 API。

小结:战术设计只有一条主线——把不变量收进值对象与聚合根,用仓储接口隔离存储,用领域事件表达事实;前两条做不到,后面所有解耦手段都会漏。

笔记加载中…