ADR 决策记录、替代链与架构债务治理手册
当同一个决定第三次被重新讨论
一次支付链路故障后,团队发现重试请求没有幂等键。有人记得当初为了缩短交付周期主动放弃了幂等存储,有人坚称这只是遗漏;会议纪要在离职成员的私人空间,代码里只剩一句 TODO。大家花了半天重新推演,最后仍回答不了三个问题:当时比较过哪些方案,什么约束让某个方案胜出,哪些变化会迫使团队重新决策。
ADR(Architecture Decision Record)解决的不是“文档不够多”,而是重要决定缺少可追溯状态。每一条 ADR 是一个不可复写历史的决策节点;目录是节点集合;supersedes / superseded-by 是演进边;Git 提交和评审记录提供证据。它不替代需求、详细设计或会议纪要,也不应该变成几百页架构说明书。
一个决定值得进入 ADR,通常因为它会改变系统边界、数据所有权、故障传播、兼容承诺、安全模型、容量成本,或者未来替换代价。普通参数调优可以留在配置说明里;“采用事务发件箱保证订单事件一致性”“公共 API 采用兼容优先的演进策略”则需要留下决策证据。
先把决策日志放进代码库
推荐把记录放在离代码足够近、又不会与普通说明混杂的位置:
docs/
└── architecture/
├── README.md
└── decisions/
├── README.md
├── adr-template.md
├── ADR-0001-record-architecture-decisions.md
└── ADR-0002-use-transactional-outbox.mddocs/architecture/decisions 由架构或模块 owner 负责,业务开发者通过普通 PR 提案。目录和 ADR 跟随代码分支一起评审,避免“代码已合并、外部知识库稍后再补”形成永久漂移。仓库需要更严格可见性时,可以把 ADR 拆到受控仓库,但必须让代码提交、需求和 ADR ID 保持可追踪;只有少数人能读取的决定,无法约束多数提交者。
MADR 4.0.0 提供完整、最小和 bare 模板。它是模板,不是运行服务;复制并按团队字段裁剪即可:
npm install --save-dev --save-exact madr@4.0.0
mkdir -p docs/architecture/decisions
cp node_modules/madr/template/adr-template-minimal.md \
docs/architecture/decisions/adr-template.mdWindows PowerShell 对应操作是:
npm install --save-dev --save-exact madr@4.0.0
New-Item -ItemType Directory -Force docs/architecture/decisions | Out-Null
Copy-Item node_modules/madr/template/adr-template-minimal.md `
docs/architecture/decisions/adr-template.md模板升级不是业务 ADR 迁移。先比较新旧模板字段,再只更新 adr-template.md;不要批量重排已经接受的历史记录,否则真正的决策变更会淹没在格式噪声里。
adr-tools 也能初始化目录、连续编号和创建替代关系:
adr init docs/architecture/decisions
adr new Use transactional outbox for order events
adr new -s 2 Replace outbox polling with CDC relay它的官方安装说明以 Homebrew、ASDF、发布包、Git Bash 和 WSL 为入口,最新正式发布线仍是 3.0.0,主体是 POSIX Shell。团队若采用它,应固定发布包并在目标 Shell 上做回归,不要把一条多年未变的 brew install 当作持续兼容承诺。纯 Windows 或需要自定义元数据的团队,复制模板加一个很小的跨平台检查器通常更透明。工具只负责机械动作,状态语义仍由团队拥有。adr-tools 官方仓库 给出了 init、new 和 new -s 的真实行为。
编号是稳定身份,不是文件数量
推荐文件名使用 ADR-NNNN-short-slug.md,正文和链接引用 ADR-NNNN。编号一旦进入主分支便不复用,即使提案被拒绝也保留;删除后复用会让旧 Issue、提交和审计证据指向另一个决定。
并行分支会同时计算出“下一个编号”。小团队可以在合并前解决冲突并重编号;高并发仓库可以先用 ADR-XXXX-slug.md 提案,由合并队列分配最终编号。不要让 CI 自动静默改文件,因为机器人提交会把评审对象和最终对象拆开。无论采用哪种方式,CI 都必须拒绝重复 ID、重复文件名和元数据 ID 与文件名不一致。
README.md 是索引,不是另一份状态数据库。它可以由脚本从 frontmatter 生成标题、状态、owner 和替代关系;手工维护两份状态迟早会出现“一处 accepted、一处 proposed”。
一条能工作的 ADR
下面这条记录故意保持短小,但已经能回答问题、方案、决定、代价和确认方式:
---
id: ADR-0002
status: accepted
owner: team-order
decision-makers:
- role: order-tech-lead
consulted:
- role: platform-dba
informed:
- role: order-oncall
supersedes: []
superseded-by: []
review-triggers:
- broker no longer supports idempotent publishing
- outbox backlog exceeds the recovery objective
links:
- issue: ISSUE-142
- service: order-api
---
# Use transactional outbox for order events
## Context and problem
The order transaction and event publish can succeed independently. A crash
between them can leave a paid order without an event or publish an event for a
rolled-back order.
## Considered options
- Write the order and outbox row in one database transaction.
- Publish directly and retry in application memory.
- Introduce a distributed transaction coordinator.
## Decision outcome
Write the order and outbox row in one local transaction. A relay publishes
pending rows with an idempotency key derived from the outbox record ID.
## Consequences
The database becomes the source of pending work. We add outbox storage,
backlog monitoring, relay ownership and duplicate-consumer tests. This avoids
a coordinator but still requires consumers to be idempotent.
## Confirmation
The integration test kills the relay after broker acknowledgement and before
marking the row sent. After restart, the event may repeat, but no committed
order is permanently missing an event.owner 是维护决定的人,不等于最初作者;decision-makers 表示有决策权的角色;consulted 与 informed 分开后,评审不会把“收到通知”误写成“已经同意”。示例使用角色而不是个人邮箱,避免人员变动后元数据立即腐烂。真实项目可以链接工单和服务目录,但不要把内部主机、客户名称、访问令牌、未公开漏洞或生产拓扑细节写进可广泛读取的仓库。
Confirmation 很关键:它把一句架构意图连回测试、指标或配置。没有确认方式的 ADR 只能证明团队曾写下一个想法,不能证明系统仍然遵守它。
状态机要比颜色标签严格
状态变化代表不同事实:
proposed 可以反复修改,因为决定尚未生效;accepted 进入主分支后,只修正错字、断链或事实链接。结论发生变化时创建新 ADR,而不是把旧结论改成新结论。rejected 记录为什么没有选择某方案;deprecated 表示仍可能存在但不再推荐新采用;superseded 表示已有明确替代者。这三种状态不能混用。
替代链必须双向:
# ADR-0002
status: superseded
superseded-by:
- ADR-0007
# ADR-0007
status: accepted
supersedes:
- ADR-0002一条新决定可以替代多条旧决定,但图必须无环,引用必须存在。若 ADR-0007 -> ADR-0002 -> ADR-0007,读者无法判断哪个结论生效;若旧记录只写 superseded 而没有目标,故障现场只能再次猜测。
用一次正反实验验证规则
先把模板保存为 ADR-0002-use-transactional-outbox.md,安装 Markdown 与 YAML 检查依赖:
npm install --save-dev --save-exact markdownlint-cli2@0.23.0 yaml@2.8.1
npx markdownlint-cli2 "docs/architecture/decisions/*.md"预期退出码为 0。随后复制一份为 ADR-0007-use-cdc-relay.md,把它设为 accepted、supersedes: [ADR-0002],并同步把 ADR-0002 改成 superseded、superseded-by: [ADR-0007]。检查器应确认两端一致。
再制造一个稳定失败:保留 ADR-0002 的 superseded-by,却删掉新文件中的 supersedes;或者再创建一份 id: ADR-0007。CI 应输出类似证据:
ADR-0002: superseded-by ADR-0007 is not reciprocated by supersedes
ADR-0007: duplicate id also used by ADR-0007-copy.md这种反向实验比“页面能渲染”更有价值:它证明损坏的是图关系和身份不变量,而不是 Markdown 外观。
CI 应检查什么
在项目脚本中把检查拆成四层,失败信息要能直接指向文件与规则:
{
"scripts": {
"docs:adr:lint": "markdownlint-cli2 \"docs/architecture/decisions/*.md\"",
"docs:adr:graph": "node scripts/check-adrs.mjs",
"docs:adr:test": "npm run docs:adr:lint && npm run docs:adr:graph"
}
}check-adrs.mjs 使用 YAML 解析器读取 frontmatter 后,至少执行这些不变量:
文件名、id 与 ADR-NNNN 一致,ID 全局唯一。status 只能取团队状态集合,accepted 必须有 owner、决定、后果和确认方式。supersedes 与 superseded-by 指向已存在节点,并且双向一致。
替代图无自环、无环路;superseded 至少有一个后继。本地相对链接存在,模板文件不被当作真实 ADR。
不要用正则自己解析 YAML。冒号、多行字符串、数组和注释都会让“看似够用”的解析器产生误判。图检查可先构建 Map<id, record>,再对 supersedes 边做深度优先搜索;访问到当前递归栈中的节点就是环。状态、节点和边都来自结构化 frontmatter,正文只负责可读叙事。
GitHub 或 GitLab 上再用所有权规则把决策目录交给稳定角色:
/docs/architecture/decisions/ @org/architecture-reviewers
/docs/architecture/decisions/ADR-00*.md @org/platform-ownersCODEOWNERS 触发请求评审,不天然等于强制批准。仓库还要启用受保护分支、必需检查和对应审批规则。服务 owner 提案,受影响模块 owner 与安全、数据或平台角色按风险参与;文档团队可以检查可读性,但不替业务 owner 做架构决定。
从提案走到生效
一次健康的决策流不需要大型委员会:
提案者创建 proposed ADR,把真实冲突、可选方案和最小确认方式写清,并链接需求或故障。受影响 owner 在 PR 中评论具体代价;敏感讨论进入受控系统,ADR 只保留可公开的结论和引用编号。决策者批准后将状态改为 accepted,与实现代码、迁移开关或验证测试在同一变更序列合并。
上线或迁移完成后运行 Confirmation;失败时回滚实现,不伪造 ADR 状态。约束变化时创建新提案,通过替代链结束旧决定。
评审要问“哪个事实能推翻这个选择”,而不只是润色措辞。只有优点没有负面后果的记录通常还没形成决定;列出十个选项却没有选择理由,则更像调研笔记。
临时例外也要成为可终止的决策
真实交付中最危险的往往不是正式方案,而是“先绕过一次”。例如支付团队暂时允许旧消费者继续读取没有幂等键的事件,如果只在聊天里批准,临时路径会在人员更替后变成永久架构。例外 ADR 应引用被偏离的原决定,记录风险 owner、允许偏离的服务集合、补偿控制、退出信号和失败时的止损动作。它仍从 proposed 进入 accepted,退出时由恢复原决定或接受替代方案的新记录终止,不能靠删除文件表示已经清理。
补偿控制必须能执行。若例外允许双写,就记录对账查询、差异指标和停止双写的开关;若例外允许较宽权限,就记录专用角色、审计事件和撤销命令;若例外允许跳过兼容门禁,就记录受影响消费者清单和逐个确认结果。评审者由此能区分“风险被暂时控制”与“风险只是被命名”。
跨仓库决定还会引入另一种断链:平台仓库接受 ADR-0042,三个服务仓库各自复制正文,几次修改后出现四个版本。更稳妥的做法是把平台 ADR 的稳定 ID 与不可变提交作为事实源,消费仓库只保存引用、采用状态和本地 Confirmation。平台决定被替代时,自动任务可以找出仍引用旧 ID 的仓库,但不能替服务 owner 宣布迁移完成;只有本地测试、配置或发布证据通过,采用状态才从 pending 变为 confirmed。
这也解释了为什么替代链不能等同于代码依赖图。ADR 的边表达决策语义,服务采用关系表达执行进度;把两者混成一个 supersedes 字段,会让“平台已经改变方向”被误读成“所有服务已经迁移”。治理系统应分别查询当前有效决定、尚未确认的采用者和仍在运行的临时例外,故障现场才知道该按哪条路径处置。
决策债务如何进入日常治理
架构债务不只存在于代码。以下现象都是决策债务:accepted 记录没有 owner;确认测试已删除;服务已迁移但旧 ADR 仍显示生效;替代链断裂;关键约束变化后无人复审;同一主题出现多个互相冲突的 accepted 节点。
不要用统一的日历天数给所有决定设过期时间。更可靠的是记录复审触发器:依赖停止维护、协议主版本变化、容量模型越过原假设、监管边界变化、事故证明失败假设错误、替代成本显著下降。CI 能检查 owner 与链接存在,定期任务则汇总触发器和人工确认结果。
团队可以观察这些趋势:
无 owner 的有效 ADR 数量是否回到零。断开的替代边和冲突 accepted 节点是否回到零。没有可执行 Confirmation 的 accepted ADR 是否持续下降。
触发复审后仍无人处理的队列年龄是否持续增长。一次重大变更能否从工单追到 ADR、实现、测试与发布证据。
这些是治理信号,不是跨团队通用 SLO。生产阈值由变更频率、监管要求、系统风险和维护人力决定。几千条 ADR 的磁盘成本通常不高,真正成本在评审等待、失效链接、索引生成和 owner 注意力;因此应记录少量高影响决定,而不是把每个代码选择都编号。
权限、敏感信息与长期保存
ADR 跟代码同库时继承仓库读权限,但附件、外部链接和导出的静态站点可能扩大可见范围。CI 拉取私有链接时使用短期只读凭证,避免把 PAT 写在 Markdown、命令参数或构建日志中。对外发布前扫描内部域名、账号、客户标识、真实事件样本、漏洞细节和预签名 URL。
高敏决定可以采用“双层记录”:广泛可读 ADR 保存背景类别、最终选择、影响和受控证据编号;具体密钥、攻击路径、客户数据与合同条款留在权限更窄的系统。不能因为细节敏感就只留下“已决定”三个字,否则维护者仍无法理解后果。
备份 ADR 仓库、保护默认分支并保留审计日志。外部知识库只能作为渲染副本,Git 中的源文件才是变更源;导出 PDF 或 HTML 时把提交 ID写入页脚,避免离线副本失去版本身份。
清理、回滚与退出工具
提案尚未接受时,可以在 PR 中撤回;已经进入主分支的 rejected ADR 不删除。accepted 决定实现失败时,先回滚代码和流量,再提交新的 ADR 或状态变更解释替代方案。git revert 能撤销错误文本提交,却不能假装已经发生的生产决策从未存在。
退出 adr-tools 或 MADR 很简单:保留 Markdown 记录和 ID,移除命令依赖,换掉模板与检查入口。迁移目录前先生成旧路径到新路径的映射,更新双向替代链和代码引用,并为已发布 URL 保留重定向。验收标准不是“新工具能打开文件”,而是任意旧 ADR ID 仍能找到、有效状态唯一、替代图无环、Git 历史连续。
当团队能从一个故障或代码变更追到当前决定、原始约束、替代链、owner 和可执行确认方式时,ADR 才真正从文档习惯变成架构控制面。
