Skip to content

几个模型都说得通时:候选比较与决策记录 ​

需求评审会上,休假要能登记工时。三位开发各画了一个模型,每个都能解释需求,每个人都觉得自己的更简单。最后按资历选了一个。半年后有人问为什么当时这样设计,没有人记得另外两个方案是什么,也不知道当时假设的哪些条件已经变了。

模型是为了某个目的而对业务的取舍:它保留一部分信息,忽略另一部分。所以同一个业务常常有几个都说得通的模型,差别在于它们保留了什么、让哪些变化容易、让哪些变化困难。这不是缺陷,问题在于选择的过程:只比较「哪个看起来简单」,选中的是当下画起来最顺手的那个;只记录最终的模型,丢掉的是「为什么不是另外两个」和「什么情况下应该重新选」。

本文是一套把模型选择变成可检查、可追溯过程的做法:什么时候需要比较候选,怎样用业务例子比较,淘汰的理由怎样记录,以及决策记录里最容易漏掉的复评条件。例子沿用子类型、类型字段还是组合里的工时登记,那里有各方案的实测。

需求表达不了借用概念、特殊值列出候选含「不改模型」用例子比较每行写明期望业务方检查期望、遗漏的例子决策记录淘汰理由、复评条件实现与验收测试测试来自例子表代码的困难来自概念缺失复评条件被触发?可观察的业务事实例子表(节选):登记 8 小时年假 / 年假不计费 / 项目预算报表不含休假 / 新增培训不漏处理 / 内部项目需先分配淘汰理由具体到例子:条件变化时可以直接判断理由是否仍然成立技术重构不改变业务概念时,不进入这个循环
图 1 · 示意:例子表贯穿整个过程。它先用来比较候选,再交给业务方检查期望,最后变成验收测试。决策记录写下每个被淘汰候选输在哪个例子上,以及这个决定在什么条件下应当重新评估;复评条件被触发时,回到列候选这一步,旧记录标为被取代而不是删除。

一、什么时候需要比较候选模型 ​

不是每个需求都要做一轮候选比较。大多数需求在现有模型里有明确的位置,直接实现即可。需要停下来比较的信号是:新需求用现有模型表达不了,或者只能扭曲地表达。几个常见的表现:

  • 借用旧概念:把休假建成一个叫「休假」的项目。能跑,但「项目」的含义变了。
  • 特殊值:用 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),常见的格式是:背景、决策、后果。对模型选择来说,再加三部分:

text
# 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 取代」,新记录里说明是哪一条复评条件被触发了。这样一串记录就是模型的演进史。

例子、候选和复评条件在记录里是连起来的:

例子(期望写明)候选复评条件(C 依赖的前提)登记 8 小时年假年假不计费项目预算报表新增「培训」内部项目先分配人员A 休假当项目被淘汰B 类型字段被淘汰C 登记目标采纳 · 0012非项目活动需要预算或计费「计费只属于客户项目」不再成立目标种类由租户自定义sealed 的封闭集合不再适用触发任一条:写新记录「取代 0012」,说明触发了哪一条;旧记录保留
图 2 · 示意:决策记录把「为什么不是另外两个」落到例子上,把「什么时候该重新选」写成可观察的业务事实。复评条件被触发时写一份新记录取代 0012,旧记录保留,模型的演进史就能追溯

七、生成的实现也是一个候选 ​

用代码生成工具实现一个模型时,例子表的作用更明显。生成的代码是一个待评审的候选实现,它可能在正常路径上完全正确,却在例子表里「年假不计费」这一行上悄悄计了费。验收测试要来自需求和例子表,而不是来自同一个生成过程;否则测试只会确认代码做了它做的事。

八、反例 ​

  • 一次画出完整模型:在没有具体例子的情况下设计「通用的」参与方、责任、类型体系。它能表达所有想得到的情况,代价是每个简单的需求都要穿过几层抽象。抽象程度由当前的例子决定。
  • 只记录最终模型:后来的人看到的是一个结论,不知道它排除了什么。
  • 用「简单」做唯一标准:简单要问「对哪些例子简单」。
  • 把技术重构当成模型变更:不涉及业务概念的重构不需要候选比较,也不需要决策记录。

九、检查清单 ​

  • 新需求只能扭曲地表达时,才启动候选比较。
  • 候选至少两个,其中一个是「不改模型」。
  • 每个候选能用业务语言读出来。
  • 用具体例子比较,例子写明期望;能做实验的格子做实验。
  • 淘汰理由具体到例子。
  • 业务方检查例子的期望和遗漏。
  • 决策记录包含候选、例子、复评条件;被取代时保留并注明触发了哪条复评条件。
  • 例子表就是验收测试,独立于任何一个候选的实现。

参考资料

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