如何编写一份有效的软件设计文档
Design Doc 的价值不在于留下多少页文档,而在于把重要决策提前到实现之前讨论。
如果一种选择写错后只需要几小时就能修改,它通常不值得占用设计评审时间;如果选错后会影响接口、数据、权限、存储或未来数年的维护成本,就应该在写代码前把约束、方案和代价说清楚。
本文主要参考 Michael Lynch 的 How to Write an Effective Software Design Document,结合实际工程工作整理出一套可直接用于编写和评审 Design Doc 的方法,并非原文的逐段翻译。
Design Doc 要解决什么问题
实现复杂项目或功能时,团队经常在写代码后才发现以下问题:
- 不同参与者理解的目标并不一致。
- 某个接口已经被其他系统依赖,无法轻易修改。
- 存储、权限或部署方案遗漏了关键约束。
- 方案可以完成正常流程,但没有定义故障行为。
- 评审一直讨论局部实现,影响架构的决策反而没有结论。
Design Doc 要做的是把这些问题提前暴露。它应该让没有参与过前期讨论的人也能理解:
- 为什么要做这个项目或功能。
- 谁会使用它,当前遇到了什么问题。
- 完成后要达到什么结果。
- 哪些内容明确不在范围内。
- 系统在正常、异常和并发场景下如何工作。
- 如何兼容已有客户端和数据,如何发布与回退。
- 哪些决策最难修改,为什么这样选择。
- 如何验证功能和非功能目标。
- 还有哪些问题尚未解决。
Design Doc 位于需求和实现之间,用来承载需要提前评审的技术决策。它不替代需求文档,也不是实现完成后的代码说明。
什么时候值得写
不是每个改动都需要 Design Doc。一个字段改名、一个按钮位置调整或一段局部重构,用 Issue 或 PR 描述通常就足够了。
可以用以下问题判断是否值得投入:
- 是否需要多人协作实现?
- 是否涉及其他团队或外部调用方?
- 是否需要持续数月开发?
- 是否会在生产环境运行很长时间?
- 目标和需求是否仍有明显歧义?
- 是否存在安全、隐私、法律或数据迁移风险?
- 核心方案如果选错,是否很难回退?
这些问题不是机械的打分表。它们共同衡量的是两件事:协调成本和错误成本。参与者越多、系统寿命越长、失败影响越大,Design Doc 越有价值。
低风险改动可能只需要一页说明,也可能完全不写;复杂项目或功能则需要更完整的文档。篇幅应与风险匹配,而不是与关注度匹配。
用“选错的代价”控制内容
Design Doc 不应该提前写完所有实现细节。否则设计阶段会变成另一种形式的编码,而且评审者很难从大量细节中找到关键问题。
判断一项内容是否应该写入,可以问:
如果现在判断错误,等实现完成后再修改,需要付出多大代价?
通常值得提前决定的内容包括:
- 对外 API、CLI 或文件格式。
- 数据模型、存储后端和迁移方式。
- 身份认证、权限模型和信任边界。
- 跨服务通信协议和故障语义。
- 部署拓扑、基础设施和难以替换的依赖。
- 向后兼容策略和旧数据处理方式。
- 可用性、延迟、容量和成本边界。
下面这些内容通常可以留到实现阶段:
- 局部变量或内部函数命名。
- 容易替换的第三方工具。
- 不影响公共行为的代码拆分。
- 可以低成本调整的 UI 细节。
- 普通的机械实现步骤。
Design Doc 不必覆盖所有细节,只需让高代价决策获得充分讨论。
第一页先讲清背景和范围
读者不应该先参加一场会议,才能看懂 Design Doc。文档第一页至少要回答 Objective、Background、Goals 和 Non-goals。
Objective
Objective 用一句话说明项目或功能要解决的问题。它应该使用普通语言,并让非当前团队的读者也能理解。
例如:
|
|
Objective 不需要解释具体实现,但必须说明系统或用户将得到什么变化。
Background
Background 说明为什么现在需要做这件事。好的背景应包含事实,而不是只有“性能不好”“维护困难”之类的判断。
可以回答:
- 当前观察到了什么现象?
- 哪些指标、日志、代码路径或事故支持这个判断?
- 问题影响哪些用户或系统?
- 之前是否尝试过其他方案?
- 为什么现有方案已经不够?
例如,与其写“数据库查询很慢”,不如说明页面延迟从多少增长到多少、数据库耗时占请求时间的比例,以及请求是否集中访问少量热点数据。
Goals
Goals 描述项目或功能完成后的结果,不要写成实现动作。
|
|
技术选型只是达到目标的手段。把手段写成目标,会让评审者难以判断是否存在更简单的方案。
Non-goals
Non-goals 用来阻止范围膨胀。它不需要列出世界上所有不做的事情,只记录读者可能合理误解为当前范围的内容。
例如,一个为单个业务实现的缓存层,可以明确:
- 不建设通用缓存平台。
- 第一版不支持跨地域部署。
- 不改变现有数据库写入模型。
有了 Non-goals,评审可以围绕当前版本作出决策,而不是不断把未来需求加入第一版。
对外功能和 API 先做同类调研
所有面向外部用户的功能和 API,发布后都会形成用户习惯和兼容性承诺,修改成本通常高于内部实现。Design Doc 在进入接口设计之前,应先说明目标受众、使用场景和现有解决方案。
对于已有同类产品或友商实现的能力,至少调查:
- 它面向哪些用户,在什么场景下使用?
- 它解决的具体问题是什么,用户原来如何处理?
- 功能入口或 API 的核心对象、操作和默认行为是什么?
- 如何处理认证、权限、错误、分页、幂等、版本和废弃?
- 当前方案有哪些限制,哪些约定已经成为用户预期?
调研的目的不是照搬友商接口,而是确认问题是否普遍存在、用户已经形成了哪些使用习惯,以及当前设计为什么需要相同或不同的选择。Design Doc 应记录可核验的公开文档、实际产品行为或用户反馈,避免只写“行业通常如此”。
如果没有同类实现,不能把“友商没有”直接当成建设理由。文档需要进一步回答:
- 目标受众是谁,规模或使用频率是否足以支撑投入?
- 用户现在遇到了什么问题,有哪些反馈、数据或失败案例?
- 现有功能、第三方产品或人工流程为什么无法解决?
- 为什么现在需要解决,继续维持现状的代价是什么?
- 发布后用什么结果判断问题已经得到改善?
如果这些问题没有证据支持,应先补用户访谈、数据分析或原型验证,而不是直接进入完整设计和实现。
用场景、接口和图说明系统行为
目标只能说明要到哪里,Scenarios 和 Interfaces 才能说明系统完成后如何被使用。
Scenarios
场景使用具体参与者和步骤描述系统行为。例如“支持通过 URL 分享报表”仍然比较抽象,可以展开成:
- 用户创建一份报表。
- 用户生成只读分享链接。
- 接收者打开链接。
- 接收者看到生成链接时的报表内容,但不能修改原始配置。
场景可以暴露需求描述里没有出现的问题:链接是否过期、数据是否实时、谁有权限访问、原作者删除报表后会怎样。
Interfaces
接口部分说明系统与人或其他系统之间的边界:
- API 的请求、响应和错误语义。
- CLI 的参数、输出和退出码。
- 配置文件或数据文件的格式。
- 用户界面的关键流程和简单草图。
这里应该把兼容性和失败行为写清楚,但不需要展开所有内部实现。
Diagrams
架构图适合表达文字难以快速说明的关系:
- 数据如何流动。
- 组件如何连接。
- 请求经过哪些服务。
- 系统依赖哪些外部资源。
- 哪些位置跨越了权限或信任边界。
图应该保留可编辑源文件。随着设计变化,能够修改的 Mermaid、D2、Graphviz 或绘图源文件,比一张无法维护的截图更有价值。
跨团队文档还需要处理术语问题。优先使用读者熟悉的名称;无法避免内部术语时,在首次出现的位置解释,术语较多时再单独增加 Glossary。
把 API 设计写成行为契约
API 设计不能停在路径、方法和字段列表。调用方还需要知道每个输入会触发什么行为,以及什么时候可以认为操作已经完成。
对外 API 至少要写清:
- 字段缺省、显式传空值和传入零值是否具有不同含义。
- 默认值、允许范围、互斥字段和非法组合。
- 资源 ID、租户、区域等归属关系如何校验。
- 创建、更新和删除是否幂等,重复请求如何识别。
- 列表接口的过滤、排序和分页结果是否稳定。
- HTTP 状态码与业务错误码分别表达什么,哪些错误允许重试。
- 批量操作是全部成功才提交,还是逐项返回成功与失败。
异步操作还需要单独定义状态模型。文档应列出所有状态、允许的状态迁移、负责推进状态的组件,以及每个状态对用户意味着什么。特别要区分以下时间点:
- 请求已经受理。
- 状态已经持久化。
- 后台任务已经执行。
- 数据面或外部系统已经生效。
如果 API 返回成功只代表“已受理”,就应说明调用方如何查询最终状态、失败后是否自动重试、卡住多久需要告警,以及删除中的资源何时可以重新使用。
把并发、一致性和失败恢复写进主流程
一个操作同时修改数据库、缓存、消息系统或外部资源时,正常流程只能说明功能可以运行,不能说明数据最终会保持一致。Design Doc 需要回答:
- 哪个系统是事实来源?
- 哪些写入必须位于同一个事务中?
- 哪些操作依赖唯一约束、版本号、锁或幂等键?
- 两个请求同时修改同一资源时,后写覆盖、拒绝冲突还是合并?
- 中间步骤失败后,系统选择回滚、补偿、重试还是等待人工处理?
- 定时对账或 reconciliation 如何发现并修复状态漂移?
配额检查、库存分配、删除前的引用检查尤其容易出现竞态。只写“先检查再写入”通常不够,还要说明检查和写入之间如何保持原子性,以及数据库约束如何作为最后一道防线。
错误也应按处理方式分类。参数错误不应重试;依赖暂时不可用可以退避重试;同一资源出现不一致配置时,可能需要停止自动推进并触发告警。批量操作允许部分成功时,响应必须能定位每一项的结果,避免调用方重复执行已经成功的部分。
写清约束和难以替换的依赖
一个方案看起来不够理想,可能是因为它受到预算、硬件、历史接口或组织能力限制。如果不写约束,评审者会不断提出实际上无法采用的方案。
Constraints 可以记录:
- 必须兼容的旧客户端或历史数据。
- 只能使用的硬件架构或运行环境。
- 成本、交付时间和人员限制。
- 上下游系统提供的接口边界。
- 不能中断的迁移或发布要求。
Dependencies / Infrastructure 则说明语言、存储、运行位置和第三方依赖。重点仍然是难以改变的部分:编程语言和存储后端通常比邮件发送服务更难替换,因此需要更充分的理由。
把兼容、迁移和发布作为设计的一部分
兼容性不能只写成“保持兼容”。文档需要列出具体对象和行为:
- 旧客户端调用新服务时,缺少的新字段采用什么默认值。
- 新客户端调用尚未升级的服务时,如何识别能力是否可用。
- 历史数据中的旧枚举、旧字段和缺失字段如何读取。
- 新旧服务实例同时运行时,是否会写出彼此无法理解的数据。
- 旧入口保留多久,新请求是否仍允许使用,响应是否继续返回。
涉及数据或存储切换时,还要选择迁移方式。在线迁移可能需要回填、双写、读流量切换和一致性核对;停机迁移流程更短,但必须说明不可用窗口和恢复步骤。无论采用哪种方式,都要写出切换条件、验证方法、回滚路径,以及旧字段、旧索引或旧服务由谁在什么时候清理。
发布计划应说明功能是否需要按用户、租户、区域或环境逐步开放,开关的默认值是什么,配置需要同步到哪些环境。回滚不能只写“关闭开关”:如果新版本已经写入新格式数据或创建外部资源,还要确认旧版本能否读取和清理这些状态。
列出影响面和验收矩阵
功能跨越多个模块时,按调用链列出影响面比只列代码目录更有用。常见检查项包括:
- 对外 API、内部 API、API 文档、客户端或 SDK。
- 用户界面、管理界面和权限控制。
- Service、后台任务、存储模型、索引和数据迁移。
- 计费、订单、配额、库存和资源生命周期。
- 配置默认值、功能开关和多环境配置。
- 审计日志、指标、告警、调试工具和 Runbook。
验收标准应从 Goals 和行为契约反推,不要只覆盖正常流程。测试矩阵至少考虑:
- 正常输入、边界值、缺省值和非法组合。
- 每条状态迁移、失败重试、重复请求和删除回收。
- 不同权限、租户、区域或环境下的行为。
- 旧客户端、历史数据和新旧版本并存。
- 并发写入、配额竞争、部分失败和补偿流程。
- 发布开关、回滚路径以及监控是否能发现异常。
每个关键目标都应对应一个可观察的验证结果。这样,评审者可以在实现前发现无法验收的目标,开发完成后也能按同一份契约确认行为。
把生产要求放进设计阶段
能够运行不等于能够长期维护。面向生产环境的 Design Doc 还需要覆盖可靠性和可观测性。
SLO
“高性能”“高可用”无法用于验收。SLO 应使用可以测量的指标,例如:
- 服务可用性。
- 请求的 P50、P95 或 P99 延迟。
- 每秒请求数或任务吞吐量。
- 支持的数据规模和并发数量。
具体数值应来自业务要求、现有基线或容量测试,不能为了让文档看起来完整而随意填写。
Monitoring 和 Alerting
有了 SLO,还要说明如何发现系统没有达到目标:
- 服务不可用时由什么指标发现?
- 延迟上升时如何区分应用、数据库和外部依赖问题?
- 哪些错误需要告警,哪些只需要记录?
- 告警由谁处理,是否有对应 Runbook?
Logging
日志设计需要同时考虑排障价值和数据风险:
- 哪些关键状态变化必须记录?
- 日志保存在哪里、保留多久?
- 谁可以访问生产日志?
- 哪些用户数据、密钥或凭证不能写入日志?
- 是否需要通过请求 ID 或资源 ID 串联调用链?
这些问题如果等到事故发生后再补,通常已经无法还原当时的状态。
在设计阶段处理安全、隐私和法律约束
只要系统处理外部输入、权限或敏感数据,就应该在设计阶段说明安全边界。
Security 至少应考虑:
- 系统的攻击面在哪里?
- 哪些输入不可信?
- 数据在哪些位置跨越信任边界?
- 服务是否对公网开放?
- 如果凭证泄漏,攻击者能访问哪些资源?
Privacy 应说明系统处理哪些敏感数据、保留多久、谁能访问,以及传输和静态存储如何保护。
涉及金融、医疗、合同数据或开源发布时,还需要明确法律与许可证要求。即使判断某项风险不适用,也可以写出理由,让评审者有机会指出遗漏。
记录未决问题和替代方案
Design Doc 不需要假装所有问题都已经解决。相反,Open issues 往往是最值得评审的部分。
每个 Open issue 至少包含:
- 需要解决的问题。
- 当前已知的候选方案。
- 各方案的主要代价。
- 下一步动作和负责人。
只有“待确认”而没有下一步的问题,很容易长期留在文档中。下一步可以是补实验、收集指标、请安全团队评审,或者由明确的决策者选择方案。
问题解决后,不要删除原讨论。将它移到 Resolved issues,并在开头写清最终决定及其理由。这样未来维护者可以知道当时为什么选择当前方案。
Alternatives considered 不需要记录每个短暂出现过的想法。保留最有竞争力、最可能被再次提出的替代方案,并用几句话说明没有采用的原因即可。
如何推进评审
写完 Design Doc 只是评审的起点。评审要让高风险问题在实现前得到反馈和结论。
在实际工作中,可以按下面的顺序推进:
- 先让最熟悉当前系统的人检查背景事实和约束。
- 再让接口调用方、运维、安全或数据负责人检查各自边界。
- 把争议整理成 Open issues,而不是散落在评论中。
- 为每个问题明确下一步和决策者。
- 决策完成后更新正文,并保留 Resolved issues。
- 实现过程中如果关键设计变化,同步更新文档。
评审不应该把大量时间花在容易修改的实现细节上。作者可以在文档开头明确本次最希望评审的决策,帮助读者把注意力放到正确的位置。
一份可直接使用的精简模板
下面的模板不是固定规范。根据项目或功能的风险选择需要的章节,低风险改动可以继续删减。
|
|
常见问题
把 Design Doc 写成实现清单
大量类名、函数名和代码步骤会掩盖需要评审的决策。实现细节只有在影响公共接口、兼容性或关键约束时才值得提前固定。
背景只有结论,没有证据
“系统很慢”无法帮助评审者判断方案是否合理。应补充指标、调用链、日志或真实失败案例。
对外功能没有同类调研
只描述自己的设计,无法判断它是否符合用户已有习惯,也无法解释为什么需要重新定义一套接口。应补充同类产品或友商的公开功能和 API;如果没有同类实现,则说明目标受众、现有问题、替代方式、建设理由和验证标准。
API 只有字段定义,没有行为契约
字段列表无法说明缺省值、重复请求、异步生效和失败重试。应补充默认行为、非法组合、状态迁移、错误分类和幂等语义。
把兼容和迁移留到实现阶段
如果设计没有说明旧客户端、历史数据、新旧版本并存和回滚后的行为,实现阶段很容易被迫修改已经评审过的接口与数据模型。
Goals 全是技术名词
“引入 Redis”“迁移到 Kubernetes”描述的是方案,不是目标。先写希望改善的用户体验或系统行为,再讨论技术选型。
没有 Non-goals
没有边界的文档会在评审中不断吸收新需求,最终既无法决策,也无法按期实现。
Open issues 没有下一步
只列问题不会让问题自动解决。需要明确补充什么证据、由谁判断,以及什么时候重新评审。
为了完整而填写虚假数字
没有依据的 SLO 和时间线不会提升设计质量。如果当前无法确定,应明确写成 Open issue,并说明获取数据的方式。
测试只覆盖正常流程
正常请求成功不能证明设计能够处理重复请求、并发修改、部分失败、历史数据和回滚。测试矩阵应直接覆盖行为契约与发布计划中的边界。
总结
有效的 Design Doc 集中回答三类问题:为什么要做、哪些决策最难修改、还有哪些风险需要在实现前解决。对于需要长期维护的功能,还要把 API 行为、状态迁移、兼容路径和失败恢复写成可以验证的契约。
开始写之前,先判断项目或功能的复杂度和风险;写作时,用“选错的代价”筛选内容;评审时,把注意力放在接口、数据、权限、可靠性和兼容性等高代价决策上。文档的价值由它促成的决策决定,而不是篇幅和章节数量。