3137 字
约 10 分钟
16
S18-B03 · 领域建模入门:实体、值对象与聚合

业务规则写在 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. 自测

  1. 用一句话区分实体与值对象,判断依据是什么?
  2. 值对象为什么必须不可变?举出可变值对象会引发的两个具体问题。
  3. "一个事务只改一个聚合"这条约束,是为未来留什么退路?
  4. 判断聚合该不该拆,最可靠的一句问话是什么?
  5. 为什么不能直接用数据库表结构推领域模型?
  6. 领域事件为什么必须在事务提交后发布,且监听方必须幂等?
S18-B03 · 领域建模入门:实体、值对象与聚合
https://www.clxhxhhr.top/posts/5994/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。