Skip to content

领域建模:从名词清单到业务知识模型 ​

把需求里的名词画成类、把「一对多」翻译成外键,得到的只是 ER 图的另一种画法。领域模型还要回答:规则由谁守、哪些概念有自己的生命周期、分类是不是真的对应不同的行为。

事件风暴 结束时,墙上留下了事件、命令、策略和一堆名词:活动、场次、参会人、报名、候补、应收、通知。最省事的做法是每个名词一个类,再加一个 RegistrationService 装所有逻辑。这样的模型能跑,但规则没有归属:容量检查写在服务里,重复报名靠某个 SQL 里的 COUNT,候补递补在一个定时任务里。下一次需求变化,没有人知道该改哪里。

本文用活动报名案例说明,怎样从名词清单走到一个能承载规则的模型;实体、值对象和聚合的具体判断放在后续三篇。

一、先说结论 ​

  • 名词只是线索,不是模型:每个名词都要追问它有没有自己的身份、生命周期和规则,很多名词最后只是属性或值。
  • 关联要用业务问题确认:「一个人能报几个场次」「候补算不算占位」这类问题的答案决定多重性和约束,不能凭数据库经验画线。
  • 有属性、有生命周期的关联本身就是概念:Registration 不是参会人与场次之间的中间表,它有状态、有规则、会被取消。
  • 操作写进模型:session.register(attendee) 说明了规则由场次守护;registrationService.insert(...) 什么也没说明。
  • 分类先问规则是否不同:只有字段取值不同,一个类型字段就够;规则不同,才值得用不同的类型表达。
  • 模型要能追踪到测试:词汇表、规则表和代码之间的对应由脚本检查,模型才不会在需求变化后悄悄过时。

二、名词搬运模型缺了什么 ​

先看直接搬运名词的结果:

java
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 在财务看来是什么?它是一个被多个团队各自解释的数字。
名词搬运知识模型SessionAttendeeRegistrationstatus=1/2/3RegistrationServicecount → if → insert;规则只在这里导入脚本、定时任务、后台工具绕过服务直接写表Session(聚合根)register · cancel · closeRegistration容量 Capacity候补有序队列同一场次内参会人唯一(R3)
图 1 · 左边的类和表一一对应,规则都在服务的几行 if 里,其他入口可以绕开;右边的场次对象拥有容量、名单和候补队列,报名、取消、截止都是它的操作,重复报名由场次内的唯一性约束

问题不在于用了类和表,而在于模型只记录了结构,没有记录知识:哪些规则必须成立、由谁负责、在什么时候检查。

三、用业务问题确认关联与约束 ​

画每一条关联线之前,先向业务方问清楚:

问题业务回答对模型的影响
一个参会人能报几个场次?不限参会人和场次之间没有直接的所有关系,由报名连接
同一场次里,同一个人能占几个位置?一个,确认或候补都算场次内按参会人唯一,对应规则 R3
候补的先后怎么定?按报名先后,取消后不保留位置候补是有序队列,顺位属于场次
场次满了之后还能不能调大容量?能,调大后按候补顺序补位容量变化会触发递补,规则属于场次
报名取消以后还在不在?在,要能查到历史和退款报名有生命周期,不是删掉一行

第二个问题的答案是一个限定关联:在一个场次的范围内,参会人标识唯一。它在模型里的表达是「场次按参会人索引它的报名」,在数据库里的表达是 UNIQUE KEY (session_id, attendee_id)。两者说的是同一条规则,模型是它的来源,唯一索引是它在存储上的最后一道防线。聚合边界 的实验里,一个只加载了计数的聚合放进了重复报名,正是唯一索引把它挡了下来。

最后一个问题说明 Registration 不是中间表。它有状态(确认、候补、取消),有自己的规则(取消窗口、递补顺序),被其他上下文引用(计费用它生成应收)。这样的关联本身就是一个概念,应该用业务词汇命名,而不是 session_attendee。

四、把操作写进模型 ​

名词搬运模型里,行为都在服务里;知识模型里,操作放在最有资格守护这条规则的对象上:

java
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 条规则都至少有一个以规则编号开头的验收用例。新增术语或规则时不更新代码和测试,检查就会失败。

领域事件操作返回的事件用事件断言验收测试规则聚合守护的不变量规则编号开头的用例热点规则或待验证假设规则表的状态列术语类型名、方法名脚本在源码中查找
图 2 · 事件变成操作的返回值,规则变成不变量,热点变成规则或待验证的假设,术语变成类型和方法名;每一条对应关系都可以用测试或脚本检查

七、模型与代码一起演进 ​

模型不是需求阶段结束时冻结的文档。这个案例里有两次修正都来自实现:

  • 初稿模型漏掉了重复报名,验收测试和客服反馈一起暴露了它(见事件风暴一文的实验);
  • 热点 H3「递补后要不要等候补者确认」没有定论,模型按直接递补实现,同时在规则表上标记「待验证」。上线后如果候补者到场率低,模型要加一个「待确认」状态,事件也会多一个。

所以模型建立和模型实现应该在同一个迭代里交替进行:画一部分、写一部分代码和测试、发现问题、回来改模型。模型阶段只用业务词汇,不出现表名、框架注解和技术类型;但模型也要能实现,不能为了概念上的纯粹去设计一个无法持久化、无法并发修改的结构。后一个约束怎样影响边界,见 聚合边界。

不同子域值得投入的建模深度也不同。报名与候补是核心域,值得这样逐条追问;活动管理只是增删改查,名词搬运模型就够了。这个判断在 从统一语言到限界上下文 里已经讨论过。

八、常见误区 ​

  • 「领域模型就是 ER 图」:ER 图描述数据怎么存,领域模型描述规则怎么成立。两者可以相似,但数据库结构不能反过来决定模型。
  • 「每个名词都是一个实体」:手机号、金额、时间段按值比较,它们是值对象;「候补」可能只是报名的一个状态。
  • 「用 UML 才算建模」:白板、Mermaid、结构化表格都可以,重要的是规则和术语能追踪到代码。
  • 「分类就用继承」:只有规则不同才值得用不同类型;取值不同用字段,变化方向不同用组合。
  • 「一次把全系统的模型建完」:一次只建一条能在两周内交付的业务链,其余部分等到真正要改时再深入。

小结 ​

从名词清单到领域模型,要做的是把知识放回模型:用业务问题确认关联和约束,把有生命周期的关联提升为概念,把操作放到拥有决策信息的对象上,只在规则真正不同时才分类。词汇表和规则表让模型能追踪到代码和测试。下一篇从身份和相等性出发,区分 实体与值对象。


配套实验

参考资料

  • 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

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权