战术设计:实体、值对象、聚合与领域事件
战术设计(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。