规格驱动的前后端对齐
我排查过的每一起前后端不对齐事故,根源都指向同一件事:两拨人各自按对一次对话的不同理解去实现,而不是按同一份书面契约。Spec-first 就是用来终结这种模式的,它比任何紧急集成会都便宜。
让我彻底转向 spec-first 的那个结账 Bug
几年前,我看着一条结账流程在周四上线,到周五早上开始悄无声息地丢订单。BE 发布了新的 POST /orders,在卡片需要 3DS 时返回 status: "pending_payment"。FE 是按一段口头描述开发的,仍然在分支判断 status === "pending"。结果是:订单被创建了,用户看到了成功页,支付从未完成。最先发现问题的是财务,而不是我们的监控。
没人偷懒,两边的代码都写得很认真。他们只是没有共享同一件两边有可能以同样方式误读的制品。这正是 spec-first 要阻止的失败模式,本文剩下的部分就是我现在一直在用的那本手册。
Schema 才是 source of truth,代码不是
我坚持的第一条规矩:不允许 FE 的 TypeScript 类型或 BE 的 ORM 模型充当规范性的契约。两者都只能是下游产物,上游是一份 OpenAPI 或 GraphQL schema——它住在自己的仓库或 package 里,独立打版本,由两边共同评审,在任何一端动手实现之前冻结。
这听上去有点官僚主义,直到你体会过反面。当 Prisma 模型是 source of truth 时,FE 是在部署后的构建失败里发现契约变了。当 TS 接口是 source of truth 时,BE 会悄悄漂移,直到某天一个生产 payload 把客户端打挂。一个中立的 schema 仓库强迫所有改动必须以契约编辑的形式被提出,而不是作为某个功能工单的附带产物。
客户端必须生成,永远不手改
一旦 spec 落地,FE 客户端和 BE 桩代码都要从它生成出来。我用 openapi-typescript 生成类型,用 openapi-fetch 当运行时客户端。BE 则从同一份文档生成 Zod schema,在 handler 边界上作为请求校验器使用。
下面这条规矩能救命:生成的文件顶部有一条 DO NOT EDIT 注释,任何在 spec 没变的前提下修改它的 PR 都会被 CI 拒掉。我用一个 pre-commit hook 里的哈希校验来执行这条。手改生成的客户端就是契约漂移的起点,永远是某个赶时间的开发者觉得自己这一行改动无伤大雅——它从来都不是无伤大雅的。
统一错误信封,两端都按它实现
契约腐化的另一个重灾区是错误路径,因为错误形状很少享受到和成功形状同等的评审力度。我会强制我设计的每个 API 都使用同一个错误信封,大致长这样:
{
"error": {
"code": "PAYMENT_DECLINED",
"message": "Card was declined by issuer",
"fields": { "card_number": "issuer_declined" },
"trace_id": "01HXYZ..."
}
}
code 是 FE 用来分支的稳定枚举。message 对人类可读,但 FE 永远不要去解析它。fields 这张 map 承载表单级校验错误。trace_id 能把一张客服工单在三十秒内变成一次日志查询。这个信封只约定一次,之后每个接口都免费继承。
先 mock,再并行实现
冻结 spec 的意义,在于两边可以在同一天开工。我会用 prism 或 msw 跑起 OpenAPI 文档,FE 整条功能都对着一个返回 schema 合规响应的 mock 服务器开发。BE 则对着同一份 spec 开发,用契约测试断言自己的 handler 与 spec 一致。
两边都不会被阻塞。等双方都 ready,集成就是最无聊的那一步:FE 指向真实后端就能跑通,因为两端都符合同一份文档。如果跑不通,bug 几乎总是 spec 有歧义、需要一次澄清性编辑,而不是 Slack 里互相甩锅一周。
用 Given/When/Then 写验收标准
对于任何跨团队的功能,我都会用 Given/When/Then 形式写验收标准,并把它贴在 spec 文档里对应的接口旁边。我用的格式是这样:
Feature: Checkout submits card requiring 3DS - Given a logged-in customer with a valid cart When they submit a card that triggers 3DS Then the API returns 202 with status "pending_payment" And the response includes a redirect_url for the 3DS challenge And the frontend renders the challenge in an iframe And the order is not marked paid until the callback arrives - Given the 3DS challenge times out after 10 minutes When the callback has not arrived Then the order transitions to "payment_abandoned" And the frontend shows a retry affordance, not a success screen
QA 从这里写测试用例。FE 工程师清楚该处理哪些状态值。BE 工程师清楚该实现哪些状态迁移。没人需要猜。
各自负责什么,必须界定清楚
我用的分工模型是刻意写得很具体的。Product 负责功能 spec,也就是用户可见的行为和验收标准。BE 负责数据契约,也就是字段名、类型、校验规则和错误分类。FE 负责交互 spec,覆盖 loading 态、乐观更新、重试行为以及空态和错误态的 UI。
当一次改动跨越其中两项时,那份修改 schema 的 PR 必须由两个负责人都签字。这条小规矩能阻止一个让人沮丧的常见模式:BE 因为 ORM 变了顺手重命名一个字段,FE 在部署当天才知道,PM 三天后才发现受影响的那个页面。
破坏性变更需要协议,不是开会
有时候你不得不做一次不向后兼容的改动。协议没有商量余地:永远不要原地删掉或重命名字段。先加上新字段,双写一个发布周期,让 FE 切到读新字段,再把旧字段拿掉。如果是语义改动(某个状态值的含义变了),那就升路径或加版本头,让旧版本一直活到埋点数据表明没人再调它为止。
执行要放在 CI 里。我会在每个修改 schema 的 PR 上跑 openapi-diff,破坏性变更会让构建失败,除非 PR 打上 breaking-change-approved 标签并附一份迁移计划链接。单这一项检查,比我搭过的任何监控面板挡住的事故都多。
集成门槛
最后一块拼图,是那条让两边能独立发布的规矩:一次发布只有在它符合一个被冻结的 spec 版本时才允许部署。我用 semver 给 spec 仓库打 tag,在 FE 和 BE 的 manifest 里分别 pin 到具体版本,并在 CI 里跑契约测试。如果两边都在 [email protected],那么以任何顺序部署都是安全的;如果版本分叉,部署门槛就会失败,强制一次协调发布。
这才是 spec-first 的全部意义。你并不是在放慢节奏多写文档,你是在用一份两边都要被约束的契约替换集成风险,而这份契约相比我开头那个结账 Bug 的代价,便宜得多。
评审时看什么
这篇文章适合用在前后端对齐评审时。别从“原则”聊起,直接拿一条真实改动来对照,看看规格里还缺什么。
- 状态命名是否一致。
- 空状态、错误状态和加载状态是否都有 API 支撑。
- 前端假设的数据字段是否进入契约。
- mock、真实接口和验收标准是否说的是同一件事。
对齐不是开会确认“都理解了”。对齐是同一条行为在设计、API、测试里有同一个名字。
前后端对齐要靠共享 fixture,不靠会议纪要
我会让前端和后端在同一个 PR 里签一组 response fixture。前端用它做组件测试,后端用它做 contract test。这样联调时争的就不是“我以为字段叫 displayName”,而是 fixture 有没有更新。
Shared contract fixture:
GET /api/orders/ord_123
{
"id": "ord_123",
"status": "partially_refunded",
"refund": {
"amount": 1200,
"currency": "USD",
"status": "pending_provider_confirmation"
}
}
Tests:
- backend serializer matches fixture
- frontend renders pending refund banner from same fixture
边界:不是每个 UI 状态都要进 API contract。纯展示排序、局部 loading 和视觉文案可以留给前端;跨端共享的数据语义必须进 spec。
字段 owner 要写在契约旁边
共享 fixture 之外,再给每个关键字段标 owner。订单 status 归后端,展示文案归前端,退款状态归支付域。字段 owner 清楚后,API 变更、UI copy、测试 fixture 和回滚判断就不会互相甩锅。
这套对齐还要覆盖错误响应。前端需要知道 400、401、409、422 的 code、message、field_errors 和 retryable 字段;后端需要用同一组 fixture 做测试。成功响应对齐了,错误没对齐,联调一样会卡。
可复制产物:契约评审包
当工作涉及 API 行为、schema、事件、重试或消费者预期时使用。它会把兼容性和发布证据提前摊开。
API 契约评审包:规格驱动的前后端对齐 本次要做的决策: - 确认契约变化是否兼容,消费者需要什么迁移动作,发布后如何观察风险。 责任人检查: - 产品责任人: - 工程责任人: - QA 或运维评审: 范围边界: - 本次包含: - 本次不包含: - 仍需确认的假设: 验收证据: - 测试或 fixture: - 日志、指标或截图: - 人工复核步骤: 契约边界:没有兼容性分类、消费者影响、重试行为和回滚说明,不进入发布。 评审追问: - 没参加需求会的人还会误解哪里? - 哪个证据能证明这次改动足够安全,可以发布?
编辑复核记录
复核日期:2026-04-28。本次补充了可复用产物,按相关主题 Hub 检查了文章定位,并收紧下一步链接,让页面更像可操作参考,而不是孤立长文。
专题阅读路径
这篇文章归入 API 契约 主题。先读 Hub,再结合下面的清单、模板或工具落到具体项目里。
继续阅读
填写表单,生成完整的功能规格 Markdown——免费使用,无需注册。
编辑说明
最近复核:2026-04-28。编辑部检查了示例、内链和可复制评审片段,确保内容更适合真实项目使用。
本文面向软件交付团队,介绍规格驱动的前后端对齐。示例均为工程场景说明,不构成法律、税务或投资建议。
- 作者信息:Daniel Marsh
- 编辑政策:文章审阅与更新方式
- 纠错:联系编辑