业务规则写在 Service 里久了会出现一种症状:同一个"订单不能重复支付"的判断,在三个入口各写一遍,改规则时漏掉一个,线上就多出一批重复扣款。这不是粗心,是规则没有落点——它散在流程代码里,而流程是按接口长的,不是按业务概念长的。
领域建模要做的事只有两件:把业务概念变成代码里的结构,让每条规则有一个唯一的、能被找到的地方。这篇讲三个最基础的工具:实体、值对象、聚合,以及它们怎么落在代码里。
1. 先分清三种东西:有身份的、无身份的、被包住的
建模时先问一个业务问题:这个东西,换掉它的属性之后,它还是"同一个"吗?
| 判断 | 实体 | 值对象 |
|---|---|---|
| 有没有身份 | 有,orderId 决定它是谁 |
没有,属性全等就是同一个 |
| 属性变了还是它吗 | 是,改地址后订单号不变 | 不是,换一个金额就是另一个金额 |
| 相等怎么判 | 按 ID 比较 | 按全部属性比较 |
| 可变吗 | 可变(状态会迁移),但只通过业务方法改 | 不可变,要改就换一个新实例 |
| 生命周期 | 有明确的创建、迁移、终结 | 随用随建,不需要追踪 |
| 典型例子 | 订单、用户、账户 | 金额、地址、时间段 |
判别口诀:能问"哪一个"的是实体,只能问"什么样的"是值对象。 "哪一个订单"有意义;"哪一个 100 元"没有意义。
值对象的实现要点只有三条:
// 值对象:不可变 + 自校验 + 按值相等
public final class Money {
private final BigDecimal amount;
private final String currency;
private Money(BigDecimal amount, String currency) { // 构造即校验
if (amount == null || amount.signum() < 0) throw new IllegalArgumentException("金额非法");
this.amount = amount.setScale(2, RoundingMode.HALF_UP);
this.currency = currency;
}
public Money add(Money other) { // 不改自己,返回新对象
if (!currency.equals(other.currency)) throw new IllegalArgumentException("币种不一致");
return new Money(amount.add(other.amount), currency);
}
@Override public boolean equals(Object o) { ... } // 按 amount + currency
}
如果把 Money 写成可变类(有 setAmount),它会带来两个现实麻烦:作为 Map 的 key 后 hashCode 变化导致查不到;以及"某段逻辑偷偷改了共享的金额对象",这类 bug 极难复现。用 final 字段 + 无 setter,是最省事的防线。
2. 聚合:一致性的边界,不是一个包
聚合回答的问题是:哪些对象必须一起改,改了才不会自相矛盾。 聚合根是这个边界上的唯一入口——外部只能通过它操作内部对象,不能绕过它直接改。
// 聚合根:订单是根,订单项只能通过根来增删
public class Order {
private final OrderId id;
private final List<OrderItem> items; // 内部实体,不暴露可变引用
private OrderStatus status;
public void addItem(Sku sku, int qty, Money price) {
if (status != CREATED) throw new IllegalStateException("已提交订单不能改明细");
if (items.size() >= 50) throw new IllegalStateException("明细超过上限");
items.add(new OrderItem(sku, qty, price));
}
public List<OrderItem> items() { return List.copyOf(items); } // 只读副本
}
"一个事务只改一个聚合" 的含义是:一个数据库事务里,只允许有一个聚合根被修改。跨聚合的一致性不放在事务里,交给领域事件或后续流程。
原因很实际:两个聚合同事务,就要求同一把锁、同一个库、同一套失败重试语义。业务一变(换库、拆服务、加缓存),整个事务就得重写。把跨界一致性留在事务外,是给未来留退路。
3. 聚合该多大:越小越好
新手的直觉是"关联的都放进来",结果得到一个巨大的聚合:订单里塞了用户、商品、优惠券、发票。后果是所有并发都撞在同一把锁上。
| 划分依据 | 结果 | 问题 |
|---|---|---|
| 按关联关系 | 聚合巨大,一个根管五张表 | 并发冲突高,加载慢,事务长 |
| 按不变量 | 聚合小,只在"必须同时成立"的地方合并 | 需要额外机制维护跨聚合一致性 |
| 按页面/接口 | 聚合随界面变化 | 界面一改模型就崩,规则无处安放 |
判断方法:问"这条规则是否必须在同一个事务里成立",是 → 同一个聚合;不是 → 拆开,用 ID 引用。
三条边界经验:
聚合划分自检
[ ] 这个聚合的不变量,能否用一句话写出来?(写不出 → 它只是数据集合)
[ ] 加载这个聚合,是否需要 join 三张以上的表?(是 → 太大了)
[ ] 两个用户同时操作它,冲突概率高吗?(高 → 说明它被当成了共享容器)
聚合之间只用 ID 引用,不用对象引用。 直接持有另一个聚合的对象,会让加载变成级联,也会让"一个事务一个聚合"自然破功。
4. 模型与表:不必一一对应
| 现象 | 表结构视角 | 领域模型视角 |
|---|---|---|
| 一个概念拆成三张表 | 正常(订单头 + 明细 + 扩展属性) | 一个聚合,内部三个对象 |
| 多个概念挤在一张表 | 正常(用户表带十几个标志位) | 可能是三个值对象 |
| 中间关联表 | 必须存在 | 往往只是值对象集合 |
| 枚举字段 | tinyint + 注释 |
枚举类型,带行为 |
直接用表结构推模型的代价是:表结构是存储决策的产物,不是业务概念的产物,字段拆并、索引冗余、历史兼容列都会渗进模型,让规则变形。反过来也成立:一个聚合可以用三张表存,也可以整段序列化进一列 JSON。映射是基础设施层的事。
5. 贫血还是充血:按"有没有规则"落地
这场争论最好的落地方式不是选边,而是逐对象判断。
| 对象情况 | 该用什么 | 判断 |
|---|---|---|
| 有不变量、有状态迁移 | 充血:规则写成方法 | 存在"合法/非法"的业务判断 |
| 只是数据传输 | 贫血:承认它是 DTO | 没有任何校验以外的逻辑 |
| 有校验但无状态迁移 | 值对象自校验 | 构造时就该拒绝非法值 |
贫血模型真正的危害不是"不够面向对象",而是:规则跑到了调用方,有几个调用方就有几份规则。 落地判据一句话:一段业务判断在调用方出现第二次,就把它搬进对象;如果对象只剩 getter/setter 且确实没有规则,它就是 DTO,不必强塞方法。
6. 领域事件:跨聚合的那条缝
聚合之间不能在同一事务里改,那"订单支付成功后要扣库存"怎么办?答案是领域事件:聚合在状态迁移时发出一个已发生的事实,由监听方各自处理。
// 在聚合内记录事件,由应用层在事务提交后发布
public void pay(PaymentId paymentId) {
if (status != CREATED) throw new IllegalStateException("状态不允许支付");
this.status = PAID;
this.events.add(new OrderPaid(id, paymentId, Instant.now()));
}
三条使用纪律:用过去式命名(OrderPaid),表示既成事实不是请求;在事务提交后发布,否则事务回滚了事件已飞出去;监听方必须幂等,重投不能造成二次扣减。事件是解耦工具,不是一致性工具——它把"必须同时成功"换成"最终会一致",代价是要有重试与对账。
7. 建模四步与最小清单
第一步 找名词与动词:从需求文档与客服工单里划出业务名词,圈出动词(提交、支付、取消)。
第二步 识别身份与生命周期:能问"哪一个"的 → 实体;只能问"什么样的" → 值对象。
第三步 找不变量:写下"任何时刻都必须成立"的句子,例如"已支付订单的金额不可改"。
第四步 划聚合边界:把必须同时成立的不变量放进同一个聚合,其余用 ID 引用。
产出一份三张表的最小清单,比画十张类图有用:
## 建模清单
### 实体
| 名称 | 身份 | 生命周期 | 关键状态 |
| Order | orderId | 创建→支付→发货→完成/取消 | CREATED/PAID/... |
### 值对象
| 名称 | 组成 | 自校验规则 |
| Money | amount + currency | 非负、两位小数、币种一致 |
### 聚合与不变量
| 聚合根 | 内含对象 | 不变量 | 跨聚合引用方式 |
| Order | OrderItem | 总额 = 明细之和;已支付不可改明细 | 用 userId、skuId 引用 |
8. 常见错误
| 错误 | 后果 | 修法 |
|---|---|---|
| 把每张表都当聚合 | 聚合数量爆炸,每个都独立事务 | 按不变量合并,一张表也可以是聚合内部对象 |
| 值对象用可变类 | hashCode 变化、共享对象被偷改 |
加 final,去掉 setter |
| 聚合之间强引用对象 | 一次加载牵连半库,事务越滚越大 | 改为 ID 引用,需要时再查 |
| 模型里塞技术细节 | @Table、HttpServletRequest 进领域层 |
映射放在 infrastructure |
| 为了"充血"硬塞方法 | 对象里出现只做转发的 getXxx2() |
没规则就承认它是 DTO |
9. 排错:五种"建了模但规则还是散着"的情况
| 现象 | 原因 | 处理 |
|---|---|---|
| 同一校验在多处重复 | 规则留在调用方,没进对象 | 找出现两次以上的判断,搬进对象 |
| 改规则要改三四个类 | 不变量没被识别,规则按字段散落 | 回到第三步,把不变量写成句子再定位 |
| 单测要构造一整套依赖 | 对象依赖框架与环境 | 领域对象不依赖容器,构造器注入依赖 |
| 事务里改了三个聚合 | 聚合边界划大了 | 拆小,跨聚合改事件 + 对账 |
| 模型和表越改越像 | 直接从表结构推模型 | 先按业务概念建模,再做映射 |
10. 边界:什么时候不需要领域建模
| 场景 | 做法 | 理由 |
|---|---|---|
| 纯 CRUD 后台,无业务判断 | 表驱动,DTO + Service | 没有不变量需要安放 |
| 报表 / 数据导出 | 查询模型(读模型) | 只读,用不着聚合一致性 |
| 一次性数据修复脚本 | 直接写 SQL | 生命周期短于建模成本 |
| 需求还在剧烈变动 | 先按用例写服务,等规则稳定再抽模型 | 概念未定,模型会反复推翻 |
一句话:领域建模是为了安放规则和不变量。 没有规则要安放时,它就是纯成本。
11. 自测
- 用一句话区分实体与值对象,判断依据是什么?
- 值对象为什么必须不可变?举出可变值对象会引发的两个具体问题。
- "一个事务只改一个聚合"这条约束,是为未来留什么退路?
- 判断聚合该不该拆,最可靠的一句问话是什么?
- 为什么不能直接用数据库表结构推领域模型?
- 领域事件为什么必须在事务提交后发布,且监听方必须幂等?