领域建模:从名词清单到业务知识模型
把需求里的名词画成类、把「一对多」翻译成外键,得到的只是 ER 图的另一种画法。领域模型还要回答:规则由谁守、哪些概念有自己的生命周期、分类是不是真的对应不同的行为。
事件风暴 结束时,墙上留下了事件、命令、策略和一堆名词:活动、场次、参会人、报名、候补、应收、通知。最省事的做法是每个名词一个类,再加一个 RegistrationService 装所有逻辑。这样的模型能跑,但规则没有归属:容量检查写在服务里,重复报名靠某个 SQL 里的 COUNT,候补递补在一个定时任务里。下一次需求变化,没有人知道该改哪里。
领域模型和 ER 图可以相似,但两者回答的问题不同:ER 图描述数据怎么存,领域模型描述规则怎么成立,数据库结构不能反过来决定模型。本文用活动报名案例说明,怎样从名词清单走到一个能承载规则的模型:名词只是线索,要追问身份、生命周期和规则;关联用业务问题确认;操作放到拥有决策信息的对象上;只在规则真正不同时才分类。实体、值对象和聚合的具体判断放在后续三篇。
一、名词搬运模型缺了什么
先看直接搬运名词的结果:
class Session { Long id; Long eventId; int capacity; }
class Attendee { Long id; String name; String phone; }
class Registration { Long id; Long sessionId; Long attendeeId; int status; } // status: 1 确认 2 候补 3 取消
class RegistrationService {
void register(Long sessionId, Long attendeeId) {
int used = registrationDao.countBySessionAndStatus(sessionId, 1);
Session s = sessionDao.find(sessionId);
registrationDao.insert(new Registration(null, sessionId, attendeeId, used < s.capacity ? 1 : 2));
}
}这个模型能回答「数据怎么存」,回答不了下面这些问题:
- 容量规则在哪里?在服务的一行
if里,其他写入报名的入口(后台导入、批量迁移)都可以绕开它。 - 同一个人能不能重复报名?模型里看不出来。
- 候补的顺序由什么决定?
id的大小?插入时间?没有人写下来。 status=2在财务看来是什么?它是一个被多个团队各自解释的数字。
问题不在于用了类和表,而在于模型只记录了结构,没有记录知识:哪些规则必须成立、由谁负责、在什么时候检查。
二、用业务问题确认关联与约束
画每一条关联线之前,先向业务方问清楚:
| 问题 | 业务回答 | 对模型的影响 |
|---|---|---|
| 一个参会人能报几个场次? | 不限 | 参会人和场次之间没有直接的所有关系,由报名连接 |
| 同一场次里,同一个人能占几个位置? | 一个,确认或候补都算 | 场次内按参会人唯一,对应规则 R3 |
| 候补的先后怎么定? | 按报名先后,取消后不保留位置 | 候补是有序队列,顺位属于场次 |
| 场次满了之后还能不能调大容量? | 能,调大后按候补顺序补位 | 容量变化会触发递补,规则属于场次 |
| 报名取消以后还在不在? | 在,要能查到历史和退款 | 报名有生命周期,不是删掉一行 |
第二个问题的答案是一个限定关联:在一个场次的范围内,参会人标识唯一。它在模型里的表达是「场次按参会人索引它的报名」,在数据库里的表达是 UNIQUE KEY (session_id, attendee_id)。两者说的是同一条规则,模型是它的来源,唯一索引是它在存储上的最后一道防线。聚合边界 的实验里,一个只加载了计数的聚合放进了重复报名,正是唯一索引把它挡了下来。
最后一个问题说明 Registration 不是中间表。它有状态(确认、候补、取消),有自己的规则(取消窗口、递补顺序),被其他上下文引用(计费用它生成应收)。这样的关联本身就是一个概念,应该用业务词汇命名,而不是 session_attendee。
关联指向自己的类型时(部门的上级是部门、任务的前置是任务),多重性之外还要确认三件事:能不能成环(模型上的自关联并不保证是一棵树,无环要单独作为约束)、关系有没有有效期(调整之后要不要保留历史)、关系两端的类别有没有限制(团队只能挂在部门下)。这三条规则在类图上都看不出来,见组织结构怎样建模。同一个人在不同时间担任不同角色时,角色本身也有有效期,见同一个人,几种角色。
三、把操作写进模型
名词搬运模型里,行为都在服务里;知识模型里,操作放在最有资格守护这条规则的对象上:
public final class Session { // 场次守护容量、重复与候补顺序
public List<RegistrationEvent> register(AttendeeId attendee, Instant now) { ... }
public List<RegistrationEvent> cancel(AttendeeId attendee, Instant now) { ... }
public void closeRegistration() { ... }
}方法名用词汇表里的词:register、cancel、closeRegistration,而不是 updateStatus(3)。返回值是事件风暴里定下的领域事件,调用方不需要去查状态字段就知道发生了什么。
判断一个操作放在哪里,问的是:哪个对象拥有做这个决定所需的全部信息? 报名需要知道容量、已确认名单和候补队列,这些都在场次里,所以 register 属于场次。如果一个决定需要跨多个场次的信息(例如「同一个人同一天不能报两个时间重叠的场次」),它就不属于任何一个场次,这时才考虑领域服务,见 分层、应用服务与仓储。
四、分类:继承、类型字段还是组合
报名平台有三种票:免费票、付费票、团体票。看到「三种」,很容易直接画一个继承体系。先问业务:
| 问题 | 回答 | 结论 |
|---|---|---|
| 三种票的报名规则一样吗? | 团体票要求至少 3 人同时报名,其他一样 | 团体票有自己的规则 |
| 付费票和团体票的计费规则一样吗? | 一样,按单价乘人数 | 价格是共性,不是分类依据 |
| 以后会不会加新票种? | 会有早鸟价、会员价 | 早鸟、会员更像价格策略,而不是新的票种 |
结论是:免费和付费的差别只在「有没有价格」,一个可空的价格或一个价格值对象就能表达;团体票多了一条报名规则,值得成为单独的类型;早鸟和会员价应该是价格策略的组合,而不是继续往继承树上加子类。
分类的判断顺序可以固定下来:先看规则是否不同,再看将来的变化落在哪个方向,最后才考虑存储怎么映射。 三种继承映射在 MySQL 上的实际差别,见 持久化与重建。
分类判断的五个判据(可替换性、封闭分类、独立维度、被引用时的身份)与实测,见子类型、类型字段还是组合;几个候选模型都说得通时怎样比较和记录,见几个模型都说得通时。
当一组分类反复出现在多个业务里(参会人、付款人、组织者都是「参与方」在不同场景下的角色),可以参考成熟的分析模式,例如把「参与方」和「角色」分开建模。但抽象程度要由当前需求约束:为了将来可能出现的场景提前建一个通用的参与方模型,通常比没有它更难改。
组织层级是分析模式的一个典型例子:把「允许怎样挂」放进知识层、把真实关系放进实例层,见组织结构怎样建模。
五、词汇表与规则表:让模型可追踪
模型最终要落在三处:词汇表、规则表和代码。词汇表比术语定义多两列:
| 术语 | 含义 | 不是什么 | 代码名 |
|---|---|---|---|
| 参会人 | 在报名上下文里占位置的人 | 不是会员中心的「会员」,也不是计费上下文的「付款人」 | AttendeeId |
| 候补 | 名额已满时排队等待的位置 | 不是报名失败 | CandidateWaitlisted |
| 递补 | 有人取消后,候补第一位拿到名额 | 不是重新报名 | CandidatePromoted |
| 应收 | 计费上下文里一笔待收的钱,由策略生成 | 不是支付单 | CreateCharge |
词汇表也是检验模型的中间产物:先从访谈记录和业务规则里整理出带定义的术语,再拿具体场景逐条核对(这个场景里出现的每个词都在表里吗?表里的定义能判断场景的结果吗?),比直接画类图更早暴露分歧。用模型帮忙起草词汇表或候选模型时要注意,同一个模型在两次会话里给出一致的结果,并不构成独立的证据,它们可能共享同一个错误的先验;确认只能来自领域专家和能推翻候选的反例场景,见候选模型与决策。模型遇到不确定的规则时应该提问并等人回答,而不是自己补上,见让代理停下来等人回答。
「不是什么」防止同一个词在评审中被悄悄换义;「代码名」让术语能在代码里找到。配套实验中,一个脚本检查 8 个术语的代码名都能在源码中找到对应的类型或方法,8 条规则都至少有一个以规则编号开头的验收用例。新增术语或规则时不更新代码和测试,检查就会失败。
表达形式不重要:白板、Mermaid、结构化表格都可以,不必非用 UML。重要的是规则和术语能追踪到代码。
六、模型与代码一起演进
模型不是需求阶段结束时冻结的文档。这个案例里有两次修正都来自实现:
- 初稿模型漏掉了重复报名,验收测试和客服反馈一起暴露了它(见事件风暴一文的实验);
- 热点 H3「递补后要不要等候补者确认」没有定论,模型按直接递补实现,同时在规则表上标记「待验证」。上线后如果候补者到场率低,模型要加一个「待确认」状态,事件也会多一个。
所以模型建立和模型实现应该在同一个迭代里交替进行:画一部分、写一部分代码和测试、发现问题、回来改模型。模型阶段只用业务词汇,不出现表名、框架注解和技术类型;但模型也要能实现,不能为了概念上的纯粹去设计一个无法持久化、无法并发修改的结构。后一个约束怎样影响边界,见 聚合边界。
不同子域值得投入的建模深度也不同。报名与候补是核心域,值得这样逐条追问;活动管理只是增删改查,名词搬运模型就够了。这个判断在 从统一语言到限界上下文 里已经讨论过。即使在核心域,也不要一次把全系统的模型建完:一次只建一条能在两周左右交付的业务链,其余部分等到真正要改时再深入。
下一篇从身份和相等性出发,区分 实体与值对象。
配套实验
- codesphere-labs/ddd/registration-acceptance-tests:规则表、词汇表(含「不是什么」与代码名)以及规则—用例—术语的可追踪检查(验证记录)
参考资料
- Eric Evans,《Domain-Driven Design: Tackling Complexity in the Heart of Software》第 3 章(模型与实现绑定)、第 5 章(关联与限定)
- Martin Fowler,《Analysis Patterns: Reusable Object Models》第 1、2 章(参与方与责任)
- Martin Fowler,AnemicDomainModel