核心问题
系统到底承诺了什么?
测试、监控、调试、告警都依赖一个前提:
我们知道什么行为算对,什么行为算错。
如果承诺没有被写清楚,验证就会变成猜谜。
例如:
用户购买课程后应该能看课。
这句话听起来清楚,但其实不够。
继续追问:
- 支付成功后多久必须能看?
- 如果支付成功但发放访问权失败,是否要自动重试?
- 退款后是立即不能看,还是本周期结束后不能看?
- 课程下架后,已购买用户还能不能看?
- 账号被封禁后,是否仍能访问已购课程?
- 企业分配课程后,员工离职是否立即失去访问权?
这些才是系统真正需要承诺的东西。
Specification:把模糊需求变成可验证规则
规格不是厚文档。
规格是:
对系统行为的明确描述。
例如:
当 payment.status 从 pending 变成 succeeded 时,
系统必须在同一个事务中创建 Purchase 和 CourseAccessGrant。
如果事务失败,payment 不应被标记为 processed。
这比:
支付成功后开通课程。
更可验证。
好的规格应该回答:
- 触发条件是什么?
- 系统必须产生什么结果?
- 哪些状态不允许?
- 失败时如何处理?
- 这个行为是否有时间要求?
- 谁可以执行这个动作?
Contract:边界之间的承诺
契约是两个边界之间的约定。
例如 API 契约:
POST /courses/:courseId/purchase
它承诺:
- 成功时返回 purchase id。
- 重复请求不会重复扣费。
- 账号被封禁时返回 403。
- 课程下架时返回 409。
- 支付处理中返回 pending 状态。
契约也可以存在于模块之间:
canAccessCourse(accountId, courseId)
它承诺:
- 不要求调用者知道访问来源。
- 会考虑购买、订阅、企业分配和撤销。
- 会返回拒绝原因。
- 不会修改系统状态。
契约越清楚,边界越稳定。
隐式契约是技术债
很多系统里,真正的契约只存在于资深工程师脑子里。
例如:
这个字段不能直接改,要走 updateSubscriptionStatus。
这个接口失败后可以安全重试。
这个 status 只有 billing worker 会写。
这个 API 返回 404 时表示用户无权限,不是真的不存在。
这些都是契约。
如果不写出来,它们就会变成隐式知识。
隐式契约的问题是:
- 新人不知道
- 测试不覆盖
- 调用方会误用
- 重构时会被破坏
- 出事故后才发现
原则:
重要契约必须显式化。
显式化方式可以是:
- 类型
- 测试
- API schema
- 状态机
- 文档
- 断言
- 错误码
- 日志 reason
用类型表达契约
类型是最快的契约反馈。
坏例子:
function canAccessCourse(accountId: string, courseId: string) {}
如果把 organizationId 误传给 courseId,类型系统发现不了。
更强的契约:
type AccountId = Brand<string, "AccountId">
type CourseId = Brand<string, "CourseId">
function canAccessCourse(accountId: AccountId, courseId: CourseId) {}
类型不能表达所有规则,但它能把很多错误提前到编辑器阶段。
再比如状态:
type CoursePublicationStatus = "draft" | "published" | "archived"
比:
status: string
更接近契约。
用测试固定契约
测试是可执行规格。
例如:
it("does not grant access when purchase transaction fails", async () => {
const payment = await createSucceededPayment()
await simulatePurchaseCreationFailure(async () => {
await expect(handlePaymentSucceeded(payment.id)).rejects.toThrow()
})
await expect(hasCourseAccessGrant(payment.accountId, payment.courseId))
.resolves.toBe(false)
})
这个测试固定了契约:
购买和访问权发放必须一致成功,不能半成功。
测试名应该像规格说明,而不是像实现说明。
用错误码表达契约
错误也是契约。
坏错误:
{
"error": "failed"
}
好错误:
{
"code": "COURSE_NOT_PURCHASABLE",
"message": "This course is not available for purchase.",
"reason": "course_archived"
}
调用方可以根据 code 做稳定处理。
用户界面可以根据 reason 展示更准确的提示。
监控可以统计不同错误原因。
错误码的价值是:
把失败方式变成系统承认的事实,而不是临时字符串。
用状态机表达契约
状态机是生命周期契约。
例如订单:
pending -> paid
pending -> canceled
paid -> refunded
它不仅说明有哪些状态,还说明哪些跳转是非法的。
所以不要让任意代码写:
order.status = "refunded"
应该通过命名动作:
markOrderPaid(orderId)
refundOrder(orderId)
cancelOrder(orderId)
这些动作就是契约入口。
用文档表达跨人契约
不是所有契约都能只靠代码表达。
例如:
退款后访问权撤销策略:
- 单课购买:立即撤销访问权。
- 订阅:当前周期结束后失效。
- 企业分配:由组织 membership 决定,不受个人退款影响。
这类规则应该写在领域文档或 ADR 里。
文档的目标不是代替代码,而是:
记录为什么系统选择了这个行为。
代码告诉你“现在怎么做”。
文档告诉你“为什么这样做”。
契约变化要被当成破坏性变更
如果一个函数、API 或状态的承诺变了,就不是普通重构。
例如原来:
canAccessCourse(accountId, courseId): boolean
现在改成:
canAccessCourse(accountId, courseId): AccessDecision
这是契约变化。
需要考虑:
- 调用方是否全部迁移?
- 旧行为是否仍被依赖?
- 测试是否覆盖新旧边界?
- 是否需要兼容层?
- 是否需要分阶段发布?
契约变化的风险常常比内部实现变化大得多。
契约检查清单
设计一个模块、API 或状态机时,问:
- 这个边界对调用方承诺什么?
- 它不承诺什么?
- 输入前置条件是什么?
- 输出后置条件是什么?
- 失败方式有哪些?
- 是否幂等?
- 是否有时间要求?
- 是否会修改状态?
- 哪些行为由测试固定?
- 哪些规则需要写进文档?
小结
- 验证依赖规格:先知道什么算对,才能证明它对。
- Specification 把模糊需求变成可验证规则。
- Contract 是边界之间的承诺。
- 隐式契约是技术债。
- 类型、测试、错误码、状态机、文档都可以表达契约。
- 错误也是契约,应该有稳定 code 和 reason。
- 契约变化要被当成破坏性变更,而不是普通重构。