ᕕ( ᐛ )ᕗ Jimyag's Blog

如何编写一份有效的软件设计文档

Design Doc 的价值不在于留下多少页文档,而在于把重要决策提前到实现之前讨论。

如果一种选择写错后只需要几小时就能修改,它通常不值得占用设计评审时间;如果选错后会影响接口、数据、权限、存储或未来数年的维护成本,就应该在写代码前把约束、方案和代价说清楚。

本文主要参考 Michael Lynch 的 How to Write an Effective Software Design Document,结合实际工程工作整理出一套可直接用于编写和评审 Design Doc 的方法,并非原文的逐段翻译。

Design Doc 要解决什么问题

实现复杂项目或功能时,团队经常在写代码后才发现以下问题:

  • 不同参与者理解的目标并不一致。
  • 某个接口已经被其他系统依赖,无法轻易修改。
  • 存储、权限或部署方案遗漏了关键约束。
  • 方案可以完成正常流程,但没有定义故障行为。
  • 评审一直讨论局部实现,影响架构的决策反而没有结论。

Design Doc 要做的是把这些问题提前暴露。它应该让没有参与过前期讨论的人也能理解:

  1. 为什么要做这个项目或功能。
  2. 谁会使用它,当前遇到了什么问题。
  3. 完成后要达到什么结果。
  4. 哪些内容明确不在范围内。
  5. 系统在正常、异常和并发场景下如何工作。
  6. 如何兼容已有客户端和数据,如何发布与回退。
  7. 哪些决策最难修改,为什么这样选择。
  8. 如何验证功能和非功能目标。
  9. 还有哪些问题尚未解决。

Design Doc 位于需求和实现之间,用来承载需要提前评审的技术决策。它不替代需求文档,也不是实现完成后的代码说明。

什么时候值得写

不是每个改动都需要 Design Doc。一个字段改名、一个按钮位置调整或一段局部重构,用 Issue 或 PR 描述通常就足够了。

可以用以下问题判断是否值得投入:

  • 是否需要多人协作实现?
  • 是否涉及其他团队或外部调用方?
  • 是否需要持续数月开发?
  • 是否会在生产环境运行很长时间?
  • 目标和需求是否仍有明显歧义?
  • 是否存在安全、隐私、法律或数据迁移风险?
  • 核心方案如果选错,是否很难回退?

这些问题不是机械的打分表。它们共同衡量的是两件事:协调成本和错误成本。参与者越多、系统寿命越长、失败影响越大,Design Doc 越有价值。

低风险改动可能只需要一页说明,也可能完全不写;复杂项目或功能则需要更完整的文档。篇幅应与风险匹配,而不是与关注度匹配。

用“选错的代价”控制内容

Design Doc 不应该提前写完所有实现细节。否则设计阶段会变成另一种形式的编码,而且评审者很难从大量细节中找到关键问题。

判断一项内容是否应该写入,可以问:

如果现在判断错误,等实现完成后再修改,需要付出多大代价?

通常值得提前决定的内容包括:

  • 对外 API、CLI 或文件格式。
  • 数据模型、存储后端和迁移方式。
  • 身份认证、权限模型和信任边界。
  • 跨服务通信协议和故障语义。
  • 部署拓扑、基础设施和难以替换的依赖。
  • 向后兼容策略和旧数据处理方式。
  • 可用性、延迟、容量和成本边界。

下面这些内容通常可以留到实现阶段:

  • 局部变量或内部函数命名。
  • 容易替换的第三方工具。
  • 不影响公共行为的代码拆分。
  • 可以低成本调整的 UI 细节。
  • 普通的机械实现步骤。

Design Doc 不必覆盖所有细节,只需让高代价决策获得充分讨论。

第一页先讲清背景和范围

读者不应该先参加一场会议,才能看懂 Design Doc。文档第一页至少要回答 Objective、Background、Goals 和 Non-goals。

Objective

Objective 用一句话说明项目或功能要解决的问题。它应该使用普通语言,并让非当前团队的读者也能理解。

例如:

1
通过在应用服务与 Postgres 之间增加缓存层,降低用户请求延迟和数据库读取压力。

Objective 不需要解释具体实现,但必须说明系统或用户将得到什么变化。

Background

Background 说明为什么现在需要做这件事。好的背景应包含事实,而不是只有“性能不好”“维护困难”之类的判断。

可以回答:

  • 当前观察到了什么现象?
  • 哪些指标、日志、代码路径或事故支持这个判断?
  • 问题影响哪些用户或系统?
  • 之前是否尝试过其他方案?
  • 为什么现有方案已经不够?

例如,与其写“数据库查询很慢”,不如说明页面延迟从多少增长到多少、数据库耗时占请求时间的比例,以及请求是否集中访问少量热点数据。

Goals

Goals 描述项目或功能完成后的结果,不要写成实现动作。

1
2
3
不合适:在生产环境中部署 Kubernetes。

更合适:减少发布新版本时的服务中断,并支持失败后自动恢复。

技术选型只是达到目标的手段。把手段写成目标,会让评审者难以判断是否存在更简单的方案。

Non-goals

Non-goals 用来阻止范围膨胀。它不需要列出世界上所有不做的事情,只记录读者可能合理误解为当前范围的内容。

例如,一个为单个业务实现的缓存层,可以明确:

  • 不建设通用缓存平台。
  • 第一版不支持跨地域部署。
  • 不改变现有数据库写入模型。

有了 Non-goals,评审可以围绕当前版本作出决策,而不是不断把未来需求加入第一版。

对外功能和 API 先做同类调研

所有面向外部用户的功能和 API,发布后都会形成用户习惯和兼容性承诺,修改成本通常高于内部实现。Design Doc 在进入接口设计之前,应先说明目标受众、使用场景和现有解决方案。

对于已有同类产品或友商实现的能力,至少调查:

  • 它面向哪些用户,在什么场景下使用?
  • 它解决的具体问题是什么,用户原来如何处理?
  • 功能入口或 API 的核心对象、操作和默认行为是什么?
  • 如何处理认证、权限、错误、分页、幂等、版本和废弃?
  • 当前方案有哪些限制,哪些约定已经成为用户预期?

调研的目的不是照搬友商接口,而是确认问题是否普遍存在、用户已经形成了哪些使用习惯,以及当前设计为什么需要相同或不同的选择。Design Doc 应记录可核验的公开文档、实际产品行为或用户反馈,避免只写“行业通常如此”。

如果没有同类实现,不能把“友商没有”直接当成建设理由。文档需要进一步回答:

  1. 目标受众是谁,规模或使用频率是否足以支撑投入?
  2. 用户现在遇到了什么问题,有哪些反馈、数据或失败案例?
  3. 现有功能、第三方产品或人工流程为什么无法解决?
  4. 为什么现在需要解决,继续维持现状的代价是什么?
  5. 发布后用什么结果判断问题已经得到改善?

如果这些问题没有证据支持,应先补用户访谈、数据分析或原型验证,而不是直接进入完整设计和实现。

用场景、接口和图说明系统行为

目标只能说明要到哪里,Scenarios 和 Interfaces 才能说明系统完成后如何被使用。

Scenarios

场景使用具体参与者和步骤描述系统行为。例如“支持通过 URL 分享报表”仍然比较抽象,可以展开成:

  1. 用户创建一份报表。
  2. 用户生成只读分享链接。
  3. 接收者打开链接。
  4. 接收者看到生成链接时的报表内容,但不能修改原始配置。

场景可以暴露需求描述里没有出现的问题:链接是否过期、数据是否实时、谁有权限访问、原作者删除报表后会怎样。

Interfaces

接口部分说明系统与人或其他系统之间的边界:

  • API 的请求、响应和错误语义。
  • CLI 的参数、输出和退出码。
  • 配置文件或数据文件的格式。
  • 用户界面的关键流程和简单草图。

这里应该把兼容性和失败行为写清楚,但不需要展开所有内部实现。

Diagrams

架构图适合表达文字难以快速说明的关系:

  • 数据如何流动。
  • 组件如何连接。
  • 请求经过哪些服务。
  • 系统依赖哪些外部资源。
  • 哪些位置跨越了权限或信任边界。

图应该保留可编辑源文件。随着设计变化,能够修改的 Mermaid、D2、Graphviz 或绘图源文件,比一张无法维护的截图更有价值。

跨团队文档还需要处理术语问题。优先使用读者熟悉的名称;无法避免内部术语时,在首次出现的位置解释,术语较多时再单独增加 Glossary。

把 API 设计写成行为契约

API 设计不能停在路径、方法和字段列表。调用方还需要知道每个输入会触发什么行为,以及什么时候可以认为操作已经完成。

对外 API 至少要写清:

  • 字段缺省、显式传空值和传入零值是否具有不同含义。
  • 默认值、允许范围、互斥字段和非法组合。
  • 资源 ID、租户、区域等归属关系如何校验。
  • 创建、更新和删除是否幂等,重复请求如何识别。
  • 列表接口的过滤、排序和分页结果是否稳定。
  • HTTP 状态码与业务错误码分别表达什么,哪些错误允许重试。
  • 批量操作是全部成功才提交,还是逐项返回成功与失败。

异步操作还需要单独定义状态模型。文档应列出所有状态、允许的状态迁移、负责推进状态的组件,以及每个状态对用户意味着什么。特别要区分以下时间点:

  1. 请求已经受理。
  2. 状态已经持久化。
  3. 后台任务已经执行。
  4. 数据面或外部系统已经生效。

如果 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 至少包含:

  1. 需要解决的问题。
  2. 当前已知的候选方案。
  3. 各方案的主要代价。
  4. 下一步动作和负责人。

只有“待确认”而没有下一步的问题,很容易长期留在文档中。下一步可以是补实验、收集指标、请安全团队评审,或者由明确的决策者选择方案。

问题解决后,不要删除原讨论。将它移到 Resolved issues,并在开头写清最终决定及其理由。这样未来维护者可以知道当时为什么选择当前方案。

Alternatives considered 不需要记录每个短暂出现过的想法。保留最有竞争力、最可能被再次提出的替代方案,并用几句话说明没有采用的原因即可。

如何推进评审

写完 Design Doc 只是评审的起点。评审要让高风险问题在实现前得到反馈和结论。

在实际工作中,可以按下面的顺序推进:

  1. 先让最熟悉当前系统的人检查背景事实和约束。
  2. 再让接口调用方、运维、安全或数据负责人检查各自边界。
  3. 把争议整理成 Open issues,而不是散落在评论中。
  4. 为每个问题明确下一步和决策者。
  5. 决策完成后更新正文,并保留 Resolved issues。
  6. 实现过程中如果关键设计变化,同步更新文档。

评审不应该把大量时间花在容易修改的实现细节上。作者可以在文档开头明确本次最希望评审的决策,帮助读者把注意力放到正确的位置。

一份可直接使用的精简模板

下面的模板不是固定规范。根据项目或功能的风险选择需要的章节,低风险改动可以继续删减。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# 项目或功能名称

## 元数据

- Author:
- Created:
- Status: Draft / In Review / Approved
- Reviewers:
- Related documents:

## 目标概述

用一句话说明项目或功能要解决的问题。

## 背景

说明当前现象、数据、原因、影响范围和已有尝试。

## 目标受众与现有方案

- 目标受众是谁,在哪些场景使用?
- 当前问题和已有处理方式是什么?
- 同类产品或友商如何提供这项功能或 API?
- 当前设计与它们有哪些相同和不同的选择,原因是什么?
- 如果没有同类实现,为什么仍值得建设,如何验证需求?

## 目标成果

- 完成后用户、团队或系统获得什么结果?

## 非目标

- 哪些容易被误解为当前范围的内容明确不做?

## 使用场景

使用具体参与者和步骤说明系统完成后的行为。

## 接口

描述 API、CLI、文件格式、错误语义和兼容性边界。

## 行为契约与状态模型

说明默认值、非法组合、幂等性、错误分类、状态迁移,以及同步或异步完成语义。

## 设计方案

说明组件、数据流和关键状态变化,附可编辑架构图。

## 一致性与失败处理

说明事实来源、事务边界、并发控制、重试、补偿和状态对账。

## 约束与依赖

列出硬约束、基础设施和难以替换的依赖。

## 兼容、迁移与发布

说明旧客户端与历史数据兼容、迁移步骤、灰度范围、配置默认值、回滚和清理计划。

## 影响范围

列出 API、客户端、界面、存储、计费、权限、配置、监控和文档等受影响部分。

## 可靠性与可观测性

说明 SLO、监控、告警、日志、容量和故障恢复。

## 安全与隐私

说明攻击面、信任边界、权限和敏感数据处理。

## 验证方案

根据目标成果和行为契约列出正常、异常、并发、兼容、迁移和回滚场景。

## 未决问题

记录问题、候选方案、代价、下一步和负责人。

## 备选方案

记录主要替代方案及未采用原因。

## 时间线

按可以交付和验证的中间产物拆分里程碑。

常见问题

把 Design Doc 写成实现清单

大量类名、函数名和代码步骤会掩盖需要评审的决策。实现细节只有在影响公共接口、兼容性或关键约束时才值得提前固定。

背景只有结论,没有证据

“系统很慢”无法帮助评审者判断方案是否合理。应补充指标、调用链、日志或真实失败案例。

对外功能没有同类调研

只描述自己的设计,无法判断它是否符合用户已有习惯,也无法解释为什么需要重新定义一套接口。应补充同类产品或友商的公开功能和 API;如果没有同类实现,则说明目标受众、现有问题、替代方式、建设理由和验证标准。

API 只有字段定义,没有行为契约

字段列表无法说明缺省值、重复请求、异步生效和失败重试。应补充默认行为、非法组合、状态迁移、错误分类和幂等语义。

把兼容和迁移留到实现阶段

如果设计没有说明旧客户端、历史数据、新旧版本并存和回滚后的行为,实现阶段很容易被迫修改已经评审过的接口与数据模型。

Goals 全是技术名词

“引入 Redis”“迁移到 Kubernetes”描述的是方案,不是目标。先写希望改善的用户体验或系统行为,再讨论技术选型。

没有 Non-goals

没有边界的文档会在评审中不断吸收新需求,最终既无法决策,也无法按期实现。

Open issues 没有下一步

只列问题不会让问题自动解决。需要明确补充什么证据、由谁判断,以及什么时候重新评审。

为了完整而填写虚假数字

没有依据的 SLO 和时间线不会提升设计质量。如果当前无法确定,应明确写成 Open issue,并说明获取数据的方式。

测试只覆盖正常流程

正常请求成功不能证明设计能够处理重复请求、并发修改、部分失败、历史数据和回滚。测试矩阵应直接覆盖行为契约与发布计划中的边界。

总结

有效的 Design Doc 集中回答三类问题:为什么要做、哪些决策最难修改、还有哪些风险需要在实现前解决。对于需要长期维护的功能,还要把 API 行为、状态迁移、兼容路径和失败恢复写成可以验证的契约。

开始写之前,先判断项目或功能的复杂度和风险;写作时,用“选错的代价”筛选内容;评审时,把注意力放在接口、数据、权限、可靠性和兼容性等高代价决策上。文档的价值由它促成的决策决定,而不是篇幅和章节数量。

#Design Doc #Software Design #Technical Writing #工程实践