一个需求文档写得很细:"用户提交报销单后,系统校验发票真伪,通过则进入审批,不通过则退回并通知"。而代码里只有 ReimbursementService.submit():校验、状态变、发通知全在一个方法里,且"不通过"的分支"只打了个日志"。
"用例"(use case)到"代码"之间,缺的不是"设计模式",而是"一个把'场景'翻译成'调用序列'的中间物"。这个"只剩一个方法"的后果是"文档与代码之间没有对账手段":没人能说"这五条场景,代码覆盖了几条"——于是"漏一条"不会被发现,只会被"用户投诉"发现。
而它缺席时,"文档里的场景"就只剩"一个方法"。
1. "中间层"的三种形态
| 形态 | 作用 |
|---|---|
| 用例图/描述 | 说清"谁在什么条件下做什么" |
| 应用服务(一个用例一个方法) | 把场景落成"调用序列" |
| 测试用例(同名) | 把场景变成"可执行断言" |
第二项是"代码层"的中间物:submitReimbursement(cmd) 里只做"编排"第二项的"边界"很清楚:"编排"= 顺序 + 事务 + 后果——它不含"怎么算校验结果",只含"校验失败时做什么"。
——校验交给领域对象、通知交给事件。
"场景覆盖率"可以"数出来",而它比"感觉完整"可靠:
// 三步对账(文档 vs 测试名)
1 从需求文档列出"主流程 + 分支"(通常 4–6 条)
2 grep 测试名里的"用例名"(如 submitReimbursement_)
3 逐条标注"有/无对应测试" → 空的那几条就是"没实现"
// 判据:分支数 = 测试数(允许一个测试覆盖两条相邻分支)
2. "场景丢在代码里"的三种表现
| 表现 | 后果 |
|---|---|
| 分支被压成日志 | "不通过"没有后续 |
| 多场景共用一个方法 | 改 A 影响 B |
| 顺序藏在 if 里 | 无人能说清"先校验还是先占号" |
第一项的"根因"常是"迭代挤出来的":第一版"只做通过路径",退回"先留着"——而"留着"的那部分,从来没人回来补。
第一项最典型:"退回并通知"在文档里是"一个动作",而代码里是"一行 log"——于是"用户永远收不到退回通知"。
"失败后果"要"三选一",而不是"打日志":
// 三种"显式后果"(同一校验失败)
1 返回错误(同步调用方需要知道)
2 改状态 + 发事件(异步流程要继续)
3 抛异常(调用方不该继续)
// 反例:log.warn("校验失败") —— 系统状态没变,用户也没被告知
3. "一个用例一个方法"的三条判据
| 判据 | 说明 |
|---|---|
| 方法名 = 用例名(动词短语) | submitReimbursement 而非 handle |
| 方法体只做"编排" | 无业务计算、无 SQL |
| 失败有"显式后果" | 抛异常/发事件,而非"打日志" |
第三项是"最容易被漏"的:"校验失败"必须"改变可见状态"第三项的"检验"很直接:"删掉那行 log,用户能察觉吗"——察觉不到,就说明"这个分支没有后果",于是它等于"没实现"。
(状态、通知、返回值)——只打日志等于"什么都没发生"。
"调用序列"的写法有模板,写起来比"想"快:
// 一个"场景"的调用序列(四行)
submitReimbursement(cmd):
1 校验:invoice.verify(cmd.invoiceNo) → 失败:状态=退回、发"退回"事件
2 建单:Reimbursement.create(cmd) → 成功:状态=待审
3 编排:发"待审"事件(审批流由订阅方启动)
// 判据:每一行"失败时的后果"都写出来
4. "用例落到代码"的三步
| 步 | 产出 |
|---|---|
| 1 列"场景"(主流程 + 分支) | 一张表 |
| 2 每个场景一行"调用序列" | 伪代码 |
| 3 一行对应一个"应用服务方法" | 真代码 |
第二步的"成本"只有"半小时":五个场景各写三行伪代码——而它省掉的是"写完代码再发现'顺序不对'"的返工。
第二步是"关键":"调用序列"写出来之后,"该有哪些领域方法"就自然浮现——而"直接写代码"会把"序列"埋进 if。
"顺序"要"显式",因为"藏在 if 里"就"没人能说清":
// 反例:顺序藏在条件里
if (needLock) { lock(); }
if (valid()) { save(); }
if (notify) { send(); }
// 正解:编排层"按顺序写",条件只影响"跳过还是失败"
lockIfNeeded(); validateOrReject(); saveOrReject(); notifyAfterCommit();
// 判据:能否用"一句话"说出"先做什么、失败怎么办"
5. "多场景共用方法"的三种代价
| 代价 | 表现 |
|---|---|
| 参数膨胀 | 一个方法 8 个参数 |
| 分支嵌套 | 四层 if |
| 变更耦合 | 改"A 提交"影响"B 撤回" |
第一项是"最直观的信号":方法参数超过 4 个,通常说明"它在服务多个场景"第一项的"另一面"是"参数对象掩盖问题":"把 8 个参数收成一个 Cmd"看起来整洁了,而"多场景共用"的问题还在——只是"从签名藏进了字段"。
——此时该"拆方法",而不是"加参数对象".
6. 排错:五种"用例落地问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 文档动作没实现 | 分支被压成日志 | 显式后果 |
方法名是 handle/process |
无场景意识 | 用例命名 |
| 改一处影响多处 | 多场景共用 | 一用例一方法 |
| 顺序说不清 | 顺序藏在 if | 显式编排 |
| 测试与文档对不上 | 无同名测试 | 用例名复用 |
"工作流引擎"在场时,"场景"落在"流程定义"里,而代码只剩"任务处理器":
// 两种"场景落点"
1 代码里(应用服务方法)——适合"分支少、变更慢"
2 引擎里(流程定义)——适合"分支多、需可视化/审计"
// 判据:分支数 > 5 且"业务方要看流程",就该上引擎
7. 边界:什么情况"一个方法包全部"是对的
| 场景 | 为什么 | 做法 |
|---|---|---|
| 纯 CRUD(无分支) | 无"场景"可分 | 一个方法 |
| 极短流程(两步) | 拆了反而碎 | 一个方法 |
| 原型/一次性脚本 | 不长期 | 随意 |
| 已有"工作流引擎" | 场景在引擎里 | 跟随 |
最后一条与"测试"有关的边界:"用例名复用"是最便宜的"文档-代码对账":文档里叫"提交报销",测试就叫 testSubmitReimbursement_rejectedWhenInvoiceInvalid——于是"读测试名"就知道"实现了哪些场景"。
判断口径一句话:"这个动作有没有'分支'且'分支后果不同'"。有(通过/退回各有一套后果),就该"一个场景一个方法";没有(只是保存),一个方法更清楚。
再补一个"组织"现实:"用例"这件事在"需求评审"里通常存在,在"代码"里消失——因为评审看的是"文档",交付看的是"接口",而"两者之间的那层"没人负责。所以"一个用例一个方法"不只是一个代码规范,它需要"有人认领这条链路":评审时把"场景列表"附在工单上,交付时用"测试名"回填——于是"文档-代码"第一次有了"对账表"。
而"对账表"的成本,是"评审时多写五行、交付时多看一眼"。
而它挡掉的,是"上线后才发现退回没通知"这类最贵的返工。
所以"用例落地"的收益,一半在"少返工",一半在"能对账"。
"能对账"这件事,本身就能减少一半争论。
8. 自测
- "中间层"的三种形态里,哪一个是"代码层"的中间物?它做什么?
- "场景丢在代码里"的三种表现里,哪一种最典型?为什么"打日志"等于"没实现"?
- "一个用例一个方法"的三条判据里,哪一条最容易被漏?
- 为什么"先写调用序列"能暴露出"该有哪些领域方法"?
- "方法参数超过 4 个"为什么是"多场景共用"的信号?
- 判断"能否一个方法包全部"的那个问题是什么?