《互联网产品的客户端架构·规模化与架构演进》11.1 架构决策记录与契约

从一次没人记得的架构决策出发,把 ADR 拆成九个必填字段、四态生命周期、集合级完整性校验与契约变更登记,并给出可运行的 TypeScript 模块与测试。文中说明哪些决策该接受、哪些该取代、契约变更如何绑定决策,以及校验失败时应该回退到哪一层。

半年后没人记得为什么

某客户端团队曾经做过一个决定:订单状态与支付状态的映射不再由各端自己维护,统一收进共享内核。决定落地后,三个端的展示终于一致,联调时间从两周缩短到三天。

半年后,新来的工程师在评审里提出:既然状态映射已经统一,为什么不在内核里顺手把退款文案也一起生成?没有人能回答「当初为什么不这么做」。翻遍文档,只有一份已经过期的需求说明,里面写着「各端自行适配」。团队只好重新讨论一遍,花了三个小时,最后得出与半年前相同的结论,但没有任何记录留下来。

这不是记忆问题,而是决策没有载体。代码记录了「做了什么」,却没有记录「为什么这样做、还考虑过什么、什么情况下应该推翻它」。架构决策记录要解决的就是这件事:让每一个影响多个团队的决策,都有一份可以被检索、被校验、被取代的短文档。

ADR 的九个必填字段

ADR 不需要写成长篇设计文档。它的价值在于结构固定,评审时能快速判断一条决策是否完整。

字段回答的问题缺失后的后果
id这份决策怎么被引用契约变更无法指向它
title一句话说清决策检索时只能靠猜
status现在是否生效过期决策被继续引用
date何时做出无法判断与版本的先后
context当时面对什么问题后人重复讨论
decision最终选择了什么代码与文档对不上
consequences接受了哪些代价只记好处,掩盖风险
alternatives还考虑过什么无法判断是否重新评估
owners谁负责解释与维护决策变成无主文档

consequences 是最容易写空的一项。多数团队只写「统一了状态」「提升了复用」,却不写「内核发布节奏变慢」「端上无法快速实验」。而后者恰恰是未来推翻决策时最重要的输入。

alternatives 也不是装饰。它记录的是「当时为什么没选另一条路」。如果半年后某个被否掉的方案因为基础设施变化变得可行,这份清单就是重新评估的起点。

owners 必须落到具体的人或稳定的团队标识,不能写「架构组」。没有 owner 的 ADR 在三个月内一定会过期,因为它既不会被提醒更新,也不会有人对错误内容负责。

状态不是标签,而是生命周期

把状态当成可以随意修改的标签,会让 ADR 变成一份永远「有效」的文档。更可靠的做法是定义状态之间的合法流转。

当前状态允许流转到含义
proposedaccepted / deprecated提议中,可接受或撤回
accepteddeprecated / superseded生效中,可废弃或被新决策取代
deprecated无不再适用,但保留历史
superseded无已被明确的后继决策替代

deprecated 与 superseded 的区别经常被忽略。deprecated 表示「这个做法不再推荐,但没有替代品」;superseded 表示「有另一份 ADR 明确接替了它」。前者可以没有后继,后者必须指向一个真实存在的 ADR,并且双方都要记录这层关系。

状态流转还要防止两种常见错误。第一种是让 deprecated 重新变成 accepted,这会让历史看起来像是决策从未失效。第二种是允许 superseded 但不填 supersededBy,结果是读者知道它被替代,却不知道被谁替代。

终态就是终态。要恢复一个已废弃的决策,正确做法是新建一份 ADR,在 context 里引用旧编号并说明为什么恢复,而不是把旧文档的状态改回去。这样时间线始终是单向的。

完整性校验查什么

单份 ADR 的字段校验只能保证「格式完整」,集合级校验才能发现「关系断裂」。一份可靠的 ADR 仓库至少要检查五类问题。

检查项触发条件处理方式
重复编号两个 ADR 使用同一 id阻断合并,重新编号
悬空引用supersedes 或 supersededBy 指向不存在的编号阻断合并
单向关系A 说被 B 取代,B 却没写取代 A阻断合并
状态不一致带 supersededBy 的 ADR 自己不是 superseded阻断合并
多重后继同一 ADR 被两个后继同时取代人工裁决,通常说明拆分没做干净

「多重后继」是唯一不该自动修复的一项。它往往意味着原决策被拆成了两个方向,需要先决定是合并成一份新 ADR,还是明确主次关系。自动选一个会让另一半决策失去来源。

集合级校验适合放进持续集成:每次修改 ADR 目录都跑一遍,失败就阻止合并。它比人工评审可靠,因为关系断裂在文本里不显眼,只有遍历整个集合才会暴露。

契约变更要绑定决策

ADR 记录「为什么」,契约记录「承诺了什么」。两者之间需要一条可追溯的边:每次破坏性契约变更,都应该指向一份已经接受的 ADR。

变更类型是否破坏兼容是否必须绑定 ADR
breaking是必须是 accepted 状态
additive否必须绑定,但可以是较小的决策
fix否必须绑定,用于解释行为修正

绑定关系带来两个好处。第一,评审契约变更时可以顺着 adrId 看到完整背景,而不是只看一行 diff。第二,当 ADR 被废弃时,可以反查出哪些契约版本建立在它之上,评估是否需要同步调整。

登记时还要防重复:同一契约的同一版本只能登记一次。重复登记通常意味着两个分支各自修改了契约版本号,合并后出现了冲突。与其让后一个覆盖前一个,不如在登记阶段直接报错。

把规则写成代码

下面这个模块把字段校验、状态流转、集合完整性检查与契约变更登记都实现为纯函数。它不依赖文件系统,也不解析 Markdown,只处理已经被读入内存的结构化数据,因此可以直接嵌进构建脚本或评审机器人。

// file: src/governance/adr-contract.ts
// tier: L1
// verify: node --test test/governance/adr-contract.test.ts

export type AdrStatus = "proposed" | "accepted" | "deprecated" | "superseded";
export type ContractChangeKind = "breaking" | "additive" | "fix";

export type Adr = {
  id: string;
  title: string;
  status: AdrStatus;
  date: string;
  context: string;
  decision: string;
  consequences: string;
  alternatives: string[];
  owners: string[];
  supersedes?: string;
  supersededBy?: string;
  contracts: string[];
};

export type ContractChange = {
  contractId: string;
  version: string;
  adrId: string;
  kind: ContractChangeKind;
  date: string;
};

export type FieldIssue = { field: string; code: "missing" | "invalid"; detail: string };
export type AdrCheck = { valid: boolean; errors: FieldIssue[] };

export type IntegrityIssue = {
  code: "duplicate-id" | "unknown-target" | "asymmetric-link" | "status-mismatch" | "multiple-successors";
  detail: string;
};

export type IntegrityReport = { valid: boolean; issues: IntegrityIssue[] };
export type TransitionResult = { ok: true; adr: Adr } | { ok: false; reason: string };
export type Registration = { ok: boolean; errors: string[]; changes: ContractChange[] };

export const ADR_STATUSES: AdrStatus[] = ["proposed", "accepted", "deprecated", "superseded"];
export const CONTRACT_CHANGE_KINDS: ContractChangeKind[] = ["breaking", "additive", "fix"];
export const ADR_REQUIRED_FIELDS: string[] = [
  "id",
  "title",
  "status",
  "date",
  "context",
  "decision",
  "consequences",
  "alternatives",
  "owners",
];

const ADR_ID_PATTERN = /^ADR-\d{3,4}$/;
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const VERSION_PATTERN = /^\d+\.\d+(\.\d+)?$/;

const TRANSITIONS: Record<AdrStatus, AdrStatus[]> = {
  proposed: ["accepted", "deprecated"],
  accepted: ["deprecated", "superseded"],
  deprecated: [],
  superseded: [],
};

function filled(value: unknown): boolean {
  if (typeof value === "string") {
    return value.trim().length > 0;
  }
  if (Array.isArray(value)) {
    return value.length > 0;
  }
  return value !== undefined && value !== null;
}

export function isAdrStatus(value: unknown): value is AdrStatus {
  return (ADR_STATUSES as unknown[]).includes(value);
}

export function missingAdrFields(input: Partial<Adr>): string[] {
  const record = input as Record<string, unknown>;
  return ADR_REQUIRED_FIELDS.filter((field) => !filled(record[field]));
}

export function validateAdr(input: Partial<Adr>): AdrCheck {
  const errors: FieldIssue[] = [];
  for (const field of missingAdrFields(input)) {
    errors.push({ field, code: "missing", detail: `${field} 不能为空` });
  }
  if (input.id !== undefined && !ADR_ID_PATTERN.test(input.id)) {
    errors.push({ field: "id", code: "invalid", detail: "id 必须形如 ADR-001" });
  }
  if (input.status !== undefined && !isAdrStatus(input.status)) {
    errors.push({ field: "status", code: "invalid", detail: "未知的 ADR 状态" });
  }
  if (input.date !== undefined && !DATE_PATTERN.test(input.date)) {
    errors.push({ field: "date", code: "invalid", detail: "date 必须形如 2026-11-02" });
  }
  if (input.status === "superseded" && !filled(input.supersededBy)) {
    errors.push({ field: "supersededBy", code: "missing", detail: "superseded 必须指向后继 ADR" });
  }
  if (input.status !== undefined && input.status !== "superseded" && filled(input.supersededBy)) {
    errors.push({ field: "supersededBy", code: "invalid", detail: "只有 superseded 才能带 supersededBy" });
  }
  return { valid: errors.length === 0, errors };
}

export function canTransition(from: AdrStatus, to: AdrStatus): boolean {
  return TRANSITIONS[from].includes(to);
}

export function applyTransition(adr: Adr, next: AdrStatus, patch: Partial<Adr> = {}): TransitionResult {
  if (!canTransition(adr.status, next)) {
    return { ok: false, reason: `${adr.status} -> ${next} 不允许` };
  }
  const merged: Adr = { ...adr, ...patch, status: next };
  if (next === "superseded" && !filled(merged.supersededBy)) {
    return { ok: false, reason: "superseded 必须提供 supersededBy" };
  }
  if (next !== "superseded" && filled(merged.supersededBy)) {
    return { ok: false, reason: "只有 superseded 才能带 supersededBy" };
  }
  return { ok: true, adr: merged };
}

export function integrityReport(adrs: Adr[]): IntegrityReport {
  const issues: IntegrityIssue[] = [];
  const byId = new Map<string, Adr>();
  for (const adr of adrs) {
    if (byId.has(adr.id)) {
      issues.push({ code: "duplicate-id", detail: adr.id });
    } else {
      byId.set(adr.id, adr);
    }
  }
  const successors = new Map<string, string[]>();
  for (const adr of adrs) {
    if (adr.supersededBy !== undefined) {
      const target = byId.get(adr.supersededBy);
      if (target === undefined) {
        issues.push({ code: "unknown-target", detail: `${adr.id} -> ${adr.supersededBy}` });
      } else {
        if (target.supersedes !== adr.id) {
          issues.push({ code: "asymmetric-link", detail: `${adr.id} -> ${adr.supersededBy}` });
        }
        if (adr.status !== "superseded") {
          issues.push({ code: "status-mismatch", detail: `${adr.id} 状态为 ${adr.status}` });
        }
      }
      const list = successors.get(adr.id) ?? [];
      list.push(adr.supersededBy);
      successors.set(adr.id, list);
    }
    if (adr.supersedes !== undefined) {
      const target = byId.get(adr.supersedes);
      if (target === undefined) {
        issues.push({ code: "unknown-target", detail: `${adr.id} supersedes ${adr.supersedes}` });
      } else if (target.supersededBy !== adr.id) {
        issues.push({ code: "asymmetric-link", detail: `${adr.id} supersedes ${adr.supersedes}` });
      }
    }
  }
  for (const [id, list] of successors) {
    if (list.length > 1) {
      issues.push({ code: "multiple-successors", detail: `${id}: ${list.join(",")}` });
    }
  }
  return { valid: issues.length === 0, issues };
}

export function registerContractChange(
  existing: ContractChange[],
  change: ContractChange,
  adrs: Adr[],
): Registration {
  const errors: string[] = [];
  const adr = adrs.find((item) => item.id === change.adrId);
  if (adr === undefined) {
    errors.push(`unknown-adr: ${change.adrId}`);
  } else if (adr.status !== "accepted") {
    errors.push(`adr-not-accepted: ${change.adrId}=${adr.status}`);
  }
  if (!(CONTRACT_CHANGE_KINDS as string[]).includes(change.kind)) {
    errors.push(`unknown-kind: ${String(change.kind)}`);
  }
  if (!VERSION_PATTERN.test(change.version)) {
    errors.push(`invalid-version: ${change.version}`);
  }
  if (!DATE_PATTERN.test(change.date)) {
    errors.push(`invalid-date: ${change.date}`);
  }
  const duplicated = existing.some(
    (item) => item.contractId === change.contractId && item.version === change.version,
  );
  if (duplicated) {
    errors.push(`duplicate-version: ${change.contractId}@${change.version}`);
  }
  if (errors.length > 0) {
    return { ok: false, errors, changes: existing };
  }
  return { ok: true, errors: [], changes: [...existing, change] };
}

export function breakingChanges(changes: ContractChange[]): ContractChange[] {
  return changes.filter((change) => change.kind === "breaking");
}

export function acceptedAdrs(adrs: Adr[]): Adr[] {
  return adrs.filter((adr) => adr.status === "accepted");
}

export function contractsOf(adrs: Adr[], contractId: string): string[] {
  return adrs
    .filter((adr) => adr.contracts.includes(contractId))
    .map((adr) => adr.id)
    .sort();
}

代码里有三个值得注意的取舍。

第一,validateAdr 只检查单份文档,不检查引用是否悬空。引用完整性需要整个集合,放在 integrityReport 里。把两者分开,调用方可以在编辑单个文件时先做快速校验,在合并前再做全量校验。

第二,applyTransition 返回结果对象而不是抛异常。状态流转失败是预期内的业务判断,不是程序错误。返回 { ok: false, reason } 让调用方可以把它转成评审意见,而不是让进程崩溃。

第三,registerContractChange 在失败时原样返回已有列表。这样调用方不需要先复制再回滚,也不会因为一次失败登记污染内存中的契约历史。

判据与反例

判断一套 ADR 机制是否真的在工作,不看文档数量,而看下面几条判据。

判据合格表现反例
可追溯每个 breaking 契约变更都能找到 ADR变更说明只写「按产品要求」
可推翻废弃决策有明确原因与替代旧文档被直接删除
可校验重复编号与悬空引用能自动发现靠人肉搜索编号
有 owner每份 ADR 有稳定负责人owner 写「全体客户端」
有代价consequences 同时写收益与成本只写「提升复用」
单向时间线终态不被改回为了复用旧编号直接改状态

一个典型反例是「把 ADR 写成技术方案」。文档里塞满了接口定义、时序图和代码片段,唯独没有「当时面对什么问题」和「否掉了哪些方案」。这种文档在决策发生时看起来很有价值,半年后却无法回答「要不要改」。技术方案描述实现,ADR 描述选择;两者不能互相替代。

另一个反例是「只给大决策写 ADR」。团队认为只有更换框架才值得记录,结果共享内核的边界、状态映射的归属、错误码的分层这些中等决策全部散落在聊天记录里。判断标准不该是决策大小,而是是否影响多个团队、是否会限制未来的选择。只要答案是肯定的,就值得一份短 ADR。

还有一种隐蔽的反例:ADR 写了,但契约变更不绑定。文档里有一份漂亮的决策记录,代码里的契约却已经改了三版,没人知道哪一版对应哪份决策。最终 ADR 变成历史陈列,契约变成事实来源,两者彻底脱节。绑定关系必须由工具强制,不能靠自觉。

小结

ADR 的核心不是文档,而是可校验的决策结构。

  • 九个必填字段里,context、consequences、alternatives 决定这份记录半年后还有没有用。
  • 状态是单向生命周期,deprecated 与 superseded 必须区分,终态不能回退。
  • 单份校验管格式,集合校验管关系,两者分层执行。
  • 契约变更必须绑定已接受的 ADR,否则决策与承诺会各自演化。

先让决策可被检索,再谈架构治理。没有 ADR 的治理,最后都会退化成「谁声音大听谁的」。

阅读导航:上一节:10.3 无障碍测试与验收 · 下一节:11.2 代码所有权与评审 。

常见错误与边界

  • 把 ADR 写成技术方案。接口与时序图会过期,决策背景才是长期资产。
  • consequences 只写收益。没有代价记录的决策,后人无法判断何时该推翻。
  • 允许终态回退。为了复用编号把 deprecated 改回 accepted,时间线就失真了。
  • superseded 不填后继。读者知道它失效,却不知道被谁接替。
  • 只做单份字段校验。悬空引用、单向关系、多重后继都藏在集合里。
  • 契约变更不绑定 ADR。文档与代码各走各的,半年后无法对应。
  • owner 写成「架构组」。没有具体负责人,ADR 不会有人维护。
  • 只给大决策写 ADR。影响多团队的中等决策同样需要记录。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查