核心问题

系统到底承诺了什么?

测试、监控、调试、告警都依赖一个前提:

我们知道什么行为算对,什么行为算错。

如果承诺没有被写清楚,验证就会变成猜谜。

例如:

用户购买课程后应该能看课。

这句话听起来清楚,但其实不够。

继续追问:

  1. 支付成功后多久必须能看?
  2. 如果支付成功但发放访问权失败,是否要自动重试?
  3. 退款后是立即不能看,还是本周期结束后不能看?
  4. 课程下架后,已购买用户还能不能看?
  5. 账号被封禁后,是否仍能访问已购课程?
  6. 企业分配课程后,员工离职是否立即失去访问权?

这些才是系统真正需要承诺的东西。

Specification:把模糊需求变成可验证规则

规格不是厚文档。

规格是:

对系统行为的明确描述。

例如:

当 payment.status 从 pending 变成 succeeded 时,
系统必须在同一个事务中创建 Purchase 和 CourseAccessGrant。
如果事务失败,payment 不应被标记为 processed。

这比:

支付成功后开通课程。

更可验证。

好的规格应该回答:

  1. 触发条件是什么?
  2. 系统必须产生什么结果?
  3. 哪些状态不允许?
  4. 失败时如何处理?
  5. 这个行为是否有时间要求?
  6. 谁可以执行这个动作?

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

这是契约变化。

需要考虑:

  1. 调用方是否全部迁移?
  2. 旧行为是否仍被依赖?
  3. 测试是否覆盖新旧边界?
  4. 是否需要兼容层?
  5. 是否需要分阶段发布?

契约变化的风险常常比内部实现变化大得多。

契约检查清单

设计一个模块、API 或状态机时,问:

  1. 这个边界对调用方承诺什么?
  2. 它不承诺什么?
  3. 输入前置条件是什么?
  4. 输出后置条件是什么?
  5. 失败方式有哪些?
  6. 是否幂等?
  7. 是否有时间要求?
  8. 是否会修改状态?
  9. 哪些行为由测试固定?
  10. 哪些规则需要写进文档?

小结

  1. 验证依赖规格:先知道什么算对,才能证明它对。
  2. Specification 把模糊需求变成可验证规则。
  3. Contract 是边界之间的承诺。
  4. 隐式契约是技术债。
  5. 类型、测试、错误码、状态机、文档都可以表达契约。
  6. 错误也是契约,应该有稳定 code 和 reason。
  7. 契约变化要被当成破坏性变更,而不是普通重构。