几个模型都说得通时:候选比较与决策记录
需求评审会上,休假要能登记工时。三位开发各画了一个模型,每个都能解释需求,每个人都觉得自己的更简单。最后按资历选了一个。半年后有人问为什么当时这样设计,没有人记得另外两个方案是什么,也不知道当时假设的哪些条件已经变了。
模型是为了某个目的而对业务的取舍:它保留一部分信息,忽略另一部分。所以同一个业务常常有几个都说得通的模型,差别在于它们保留了什么、让哪些变化容易、让哪些变化困难。这不是缺陷,问题在于选择的过程:只比较「哪个看起来简单」,选中的是当下画起来最顺手的那个;只记录最终的模型,丢掉的是「为什么不是另外两个」和「什么情况下应该重新选」。
本文是一套把模型选择变成可检查、可追溯过程的做法:什么时候需要比较候选,怎样用业务例子比较,淘汰的理由怎样记录,以及决策记录里最容易漏掉的复评条件。例子沿用子类型、类型字段还是组合里的工时登记,那里有各方案的实测。
一、什么时候需要比较候选模型
不是每个需求都要做一轮候选比较。大多数需求在现有模型里有明确的位置,直接实现即可。需要停下来比较的信号是:新需求用现有模型表达不了,或者只能扭曲地表达。几个常见的表现:
- 借用旧概念:把休假建成一个叫「休假」的项目。能跑,但「项目」的含义变了。
- 特殊值:用
project_id = 0表示「不属于任何项目」,用负数表示某种特殊状态。 - 字段含义漂移:同一个字段在一部分记录里是 A 的意思,在另一部分记录里是 B 的意思。
- 同一个条件分支在多处出现:
if (project.isLeave())散落在计费、报表、审批里。 - 业务方读不懂:把模型讲给业务方听,需要一句「技术上是这样,实际上的意思是……」来翻译。
这些信号说明模型缺了一个概念,或者有一个概念承担了两种职责。这时候才值得列出几个候选,而不是在原模型上继续打补丁。
二、列出候选,包括「不改模型」
候选至少两个,其中一个通常是「不改模型、在现有概念上扩展」,它是比较的基线。工时登记的例子:
| 候选 | 模型 | 一句话描述 |
|---|---|---|
| A | 休假建成一个内部项目 | 不改模型 |
| B | 项目加一个类型字段:CLIENT、INTERNAL、LEAVE | 扩展现有概念 |
| C | 提炼共同概念「登记目标」,项目和非项目活动是它的两类 | 引入新概念 |
写候选时,每个都要能用业务语言读出来:「工时登记在一个登记目标上;登记目标可以是项目,也可以是休假、培训这样的非项目活动。」读不顺的候选,往往是技术结构而不是业务模型。
三、用业务例子比较,而不是用感觉比较
比较候选最有效的工具是一组具体的业务例子。例子来自需求本身,包括已经确定的和业务方认为「可能会有」的,每个例子写清期望的结果:
| 例子 | 期望 | A:休假当项目 | B:类型字段 | C:共同概念 |
|---|---|---|---|---|
| 登记 8 小时年假 | 成功 | 能表达 | 能表达 | 能表达 |
| 年假不计费 | 不出现在账单里 | 要在计费处特判这个项目 | 要在计费处判断类型 | 计费只属于客户项目,年假没有这个操作 |
| 项目预算报表 | 不含休假 | 报表要排除这个特殊项目 | 报表要按类型过滤 | 报表只查项目 |
| 新增「培训」 | 不漏处理 | 再建一个特殊项目,所有特判再加一处 | 加类型值;带 default 的分支不报错 | 加子类型;穷尽的 switch 编译失败 |
| 某个内部项目需要先分配人员 | 可单独设置 | 与分类无关 | 若绑定到类型则表达不了 | 作为独立属性,不绑定分类 |
这张表有三个作用。第一,它把「简单」拆成了具体的代价:A 在第一行最简单,但在第二到第四行都要付出特判;第二,它暴露了候选之间真正的分歧点,这里是「计费是不是所有目标都有的操作」;第三,它会直接变成验收测试,表格里的每一行都是一个测试用例的输入和期望,不依赖任何一个候选的实现。
表格里有两格可以用实验确认,就不应停留在推理:新增类型值时带 default 的分支不报错、sealed 接口加穷尽 switch 编译失败,以及休假继承项目后统一计费时中途失败,实测见子类型、类型字段还是组合。
四、淘汰的理由要具体到例子
比较的结果不只是「选 C」,还要记下 A 和 B 是被哪一行淘汰的:
- A 被淘汰:「年假不计费」和「项目预算报表」两个例子都需要对一个具体项目做特判;每增加一种非项目活动,特判翻倍。
- B 被淘汰:「年假不计费」要求项目类型决定一个操作是否存在,这是规则差异,不是取值差异;类型字段加
default分支在新增类型时不报错。
具体到例子的淘汰理由有一个好处:条件变了,可以直接判断理由是否还成立。如果将来业务确认非项目活动永远只有「休假」一种,并且计费规则改成不区分类型,A 的淘汰理由就不再成立。笼统的「A 不够优雅」做不到这一点。
五、业务方检查与实现反馈
候选比较不是开发内部的事。把选中的模型连同例子表一起讲给业务方,让他们检查两件事:例子的期望对不对;有没有遗漏的例子。业务方补充的例子(「培训是要计费的,客户买了培训服务」)常常会推翻一个已经选定的候选,越早发现,代价越小。
实现阶段也会反馈到模型。几种值得回到模型讨论的信号:
- 到处都是类型判断:模型里的分类没有承担它应有的行为。
- 一个对象被两个聚合同时修改:边界可能画错了,见聚合边界。
- 测试需要构造大量与用例无关的数据:模型里的关联可能比需求要求的更强。
不是每次重构都要改模型。提取方法、调整包结构这类技术重构不改变业务概念,不需要回到候选比较;只有当代码的困难来自业务概念本身的缺失或错位时,才回到第一节。
六、决策记录:最容易漏掉的是复评条件
把这次选择写成一份决策记录(ADR),常见的格式是:背景、决策、后果。对模型选择来说,再加三部分:
# 0012 工时登记在「登记目标」上,而不是项目上
状态:已采纳(2026-10-16)
背景
需要登记休假、培训等非项目活动的工时。现有模型中工时只能挂在项目上。
候选
A 休假建成内部项目;B 项目加类型字段;C 提炼共同概念「登记目标」。
例子与结果
见 examples/work-target.md(5 个例子,期望与各候选的表达方式)。
A 被「年假不计费」「项目预算报表」淘汰:需要对具体项目特判。
B 被「年假不计费」淘汰:计费是否存在是规则差异;新增类型时 default 分支不报错。
决策
采用 C。登记目标用 sealed 接口;计费只属于客户项目。
存储:work_target 父表分配身份,time_entry 外键引用它。
后果
+ 新增目标类型时,未处理的分支在编译期列出。
- 需要迁移:现有工时的 project_id 改为 target_id;所有登记目标都要在父表里有一行。
复评条件
- 非项目活动需要预算或计费时(C 的「计费只属于客户项目」不再成立)。
- 登记目标的种类需要由租户自定义时(sealed 的封闭集合不再适用,改为开放的实现)。复评条件是这份记录里最有价值、也最常被省略的部分。它把「这个设计在什么前提下是对的」写成可以检查的条件。没有它,后来的人只能二选一:要么不敢动一个看起来过时的设计,要么不知道当初的约束就把它推翻了。复评条件要写成可观察的业务事实(「非项目活动需要预算」),而不是「将来需要时」。
记录被新的决定取代时,不删除,状态改为「被 0027 取代」,新记录里说明是哪一条复评条件被触发了。这样一串记录就是模型的演进史。
例子、候选和复评条件在记录里是连起来的:
七、生成的实现也是一个候选
用代码生成工具实现一个模型时,例子表的作用更明显。生成的代码是一个待评审的候选实现,它可能在正常路径上完全正确,却在例子表里「年假不计费」这一行上悄悄计了费。验收测试要来自需求和例子表,而不是来自同一个生成过程;否则测试只会确认代码做了它做的事。
八、反例
- 一次画出完整模型:在没有具体例子的情况下设计「通用的」参与方、责任、类型体系。它能表达所有想得到的情况,代价是每个简单的需求都要穿过几层抽象。抽象程度由当前的例子决定。
- 只记录最终模型:后来的人看到的是一个结论,不知道它排除了什么。
- 用「简单」做唯一标准:简单要问「对哪些例子简单」。
- 把技术重构当成模型变更:不涉及业务概念的重构不需要候选比较,也不需要决策记录。
九、检查清单
- 新需求只能扭曲地表达时,才启动候选比较。
- 候选至少两个,其中一个是「不改模型」。
- 每个候选能用业务语言读出来。
- 用具体例子比较,例子写明期望;能做实验的格子做实验。
- 淘汰理由具体到例子。
- 业务方检查例子的期望和遗漏。
- 决策记录包含候选、例子、复评条件;被取代时保留并注明触发了哪条复评条件。
- 例子表就是验收测试,独立于任何一个候选的实现。
参考资料
- Michael Nygard,Documenting Architecture Decisions
- Eric Evans,《Domain-Driven Design: Tackling Complexity in the Heart of Software》第 1 章(知识消化)与第 3 章(模型与实现的绑定)
- Gojko Adzic,《Specification by Example》
- ADR GitHub 组织:Architecture Decision Records