领域建模:从名词清单到业务知识模型
把需求里的名词画成类、把「一对多」翻译成外键,得到的只是 ER 图的另一种画法。领域模型还要回答:规则由谁守、哪些概念有自己的生命周期、分类是不是真的对应不同的行为。
事件风暴 结束时,墙上留下了事件、命令、策略和一堆名词:活动、场次、参会人、报名、候补、应收、通知。最省事的做法是每个名词一个类,再加一个 RegistrationService 装所有逻辑。这样的模型能跑,但规则没有归属:容量检查写在服务里,重复报名靠某个 SQL 里的 COUNT,候补递补在一个定时任务里。下一次需求变化,没有人知道该改哪里。
本文用活动报名案例说明,怎样从名词清单走到一个能承载规则的模型;实体、值对象和聚合的具体判断放在后续三篇。
一、先说结论
- 名词只是线索,不是模型:每个名词都要追问它有没有自己的身份、生命周期和规则,很多名词最后只是属性或值。
- 关联要用业务问题确认:「一个人能报几个场次」「候补算不算占位」这类问题的答案决定多重性和约束,不能凭数据库经验画线。
- 有属性、有生命周期的关联本身就是概念:
Registration不是参会人与场次之间的中间表,它有状态、有规则、会被取消。 - 操作写进模型:
session.register(attendee)说明了规则由场次守护;registrationService.insert(...)什么也没说明。 - 分类先问规则是否不同:只有字段取值不同,一个类型字段就够;规则不同,才值得用不同的类型表达。
- 模型要能追踪到测试:词汇表、规则表和代码之间的对应由脚本检查,模型才不会在需求变化后悄悄过时。
二、名词搬运模型缺了什么
先看直接搬运名词的结果:
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 条规则都至少有一个以规则编号开头的验收用例。新增术语或规则时不更新代码和测试,检查就会失败。
七、模型与代码一起演进
模型不是需求阶段结束时冻结的文档。这个案例里有两次修正都来自实现:
- 初稿模型漏掉了重复报名,验收测试和客服反馈一起暴露了它(见事件风暴一文的实验);
- 热点 H3「递补后要不要等候补者确认」没有定论,模型按直接递补实现,同时在规则表上标记「待验证」。上线后如果候补者到场率低,模型要加一个「待确认」状态,事件也会多一个。
所以模型建立和模型实现应该在同一个迭代里交替进行:画一部分、写一部分代码和测试、发现问题、回来改模型。模型阶段只用业务词汇,不出现表名、框架注解和技术类型;但模型也要能实现,不能为了概念上的纯粹去设计一个无法持久化、无法并发修改的结构。后一个约束怎样影响边界,见 聚合边界。
不同子域值得投入的建模深度也不同。报名与候补是核心域,值得这样逐条追问;活动管理只是增删改查,名词搬运模型就够了。这个判断在 从统一语言到限界上下文 里已经讨论过。
八、常见误区
- 「领域模型就是 ER 图」:ER 图描述数据怎么存,领域模型描述规则怎么成立。两者可以相似,但数据库结构不能反过来决定模型。
- 「每个名词都是一个实体」:手机号、金额、时间段按值比较,它们是值对象;「候补」可能只是报名的一个状态。
- 「用 UML 才算建模」:白板、Mermaid、结构化表格都可以,重要的是规则和术语能追踪到代码。
- 「分类就用继承」:只有规则不同才值得用不同类型;取值不同用字段,变化方向不同用组合。
- 「一次把全系统的模型建完」:一次只建一条能在两周内交付的业务链,其余部分等到真正要改时再深入。
小结
从名词清单到领域模型,要做的是把知识放回模型:用业务问题确认关联和约束,把有生命周期的关联提升为概念,把操作放到拥有决策信息的对象上,只在规则真正不同时才分类。词汇表和规则表让模型能追踪到代码和测试。下一篇从身份和相等性出发,区分 实体与值对象。
配套实验
- 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