ADR 与 Service Catalog:我在架构治理里反复用到的两类文档
目录
📦 本文基于的完整项目源码:meirongdev/shop
服务数到 5 个时,靠口口相传和 README 还能维护。到 16 个时,新人入职第二周问的"order-service 发什么事件、谁在消费、出问题找谁"已经没有一个人能答全。这是我在 shop-platform 遇到的一个规模拐点。本文记录两份成本相对较低、回报也比较稳定的治理文档:ADR(架构决策记录)和 Service Catalog(服务目录)。至少在我这套项目里,它们值得投入。
一、ADR 是什么、为什么需要#
Q:架构文档不是已经在 README 和 design doc 里了吗?
它们描述当前的形态,不描述为什么形成这种形态。
举例:项目里有一条规则"@HttpExchange 客户端必须放在调用方模块、不能放共享模块"。架构文档会写"这是约定"。但答不出来——
- 为什么?业界普遍是"共享 client SDK"模式。
- 之前是不是试过共享模式?为什么放弃?
- 改回共享模式会发生什么?
这些问题的答案是决策的上下文,半年后所有当事人都会忘。新人提 PR 想"重构"成共享模式时,没人能说出具体反对理由——只能凭"这是规矩"挡回去。一年后规矩本身被推翻,团队又走了一遍同样的路。
ADR 把这个上下文以最小代价封进版本历史。一份 200 字的 markdown 就能保住"为什么"的链路。
Q:ADR 跟 design doc 的区别?
| 维度 | ADR | Design Doc |
|---|---|---|
| 体量 | 一页(200-500 字) | 几页到几十页 |
| 时机 | 决策点 | 实现前 |
| 内容 | 选了什么、放弃了什么、为什么 | 怎么实现 |
| 寿命 | 永久(即使 superseded 也保留) | 实现完通常归档 |
| 可写时机 | 当时写或事后追溯都行 | 必须事前 |
design doc 回答"how",ADR 回答"why"。两者互补。
二、MADR 模板:用最少的字段保住决策的关键信息#
Q:ADR 用什么模板?
我使用的是 MADR 0.4.0。它是社区里比较流行的模板之一,Spotify Backstage 默认支持,Devoxx 2024 几个 talk 都引用过。
# NNNN — {标题,主动语态}
- Status: Proposed | Accepted | Rejected | Superseded by [NNNN](...)
- Date: YYYY-MM-DD
- Deciders: {决策方}
## Context and problem statement
{什么具体场景驱动这个决策?两三段。避免重述项目背景。}
## Decision drivers
- {驱动因素 1}
- {驱动因素 2}
## Considered options
1. **Option A** — {一句话概括}
2. **Option B** — {一句话概括}
3. **Option C / 现状 / "什么都不做"** — {一句话概括}
## Decision outcome
**Chosen: Option X — {重述}**
{一段话讲为什么是这个选项。引用前面的 drivers。}
## Consequences
**Positive**
- {能做什么了}
**Negative**
- {接受什么代价}
- {已知风险和如何监控}
## Pros and cons of the options
### Option A — {名字}
- 👍 ...
- 👎 ...
### Option B — {名字}
- 👍 ...
- 👎 ...
## Links
- {相关 ADR、PR、ArchUnit 规则、runbook}
关键字段是 Context 和 Considered options。读一份 ADR 时,你最想知道的是"我现在面对的问题,他们当时是不是也想过"——这两个字段就是答案。
Q:什么时候应该写 ADR?
四条标准(满足任一即写):
- 影响多个模块或多个团队
- 难以撤销(数据形状、对外契约、运行拓扑)
- 合理的人会有不同意见
- 是为了避免某个其他选项才做的——把"为什么不那样"留下来
不要给以下场景写 ADR(这是常见错误):
- 测试框架版本选择
- 单个模块内的文件命名
- 代码风格选择
这类决策属于"约定",写在 CLAUDE.md 或 ENGINEERING-STANDARDS.md 里更合适。ADR 是给"会有人事后挑战"的决策准备的。
三、Retrospective ADR:补齐历史决策的合法操作#
Q:项目已经跑了一年,能不能把已经做了的决策事后写成 ADR?
完全可以,社区通常把这类做法叫作 retrospective ADR。条件是:
- 真实记录决策当时的 context(哪怕是"我们当时只考虑了这一种方案"也要写)
- 不要把"现在的最佳理解"伪造成"当时的理由"——会误导未来的决策
例如,shop-platform 项目里有四份 ADR,三份是 retrospective:
docs/adr/0001-record-architecture-decisions.md (meta-ADR)
docs/adr/0002-per-caller-http-exchange-clients.md (retrospective; commit 0e6a28d)
docs/adr/0003-transactional-outbox-pattern.md (retrospective)
docs/adr/0004-per-event-schema-version.md (retrospective; commit 8c6ac2a)
每份引用了具体的 commit hash 作为决策时间锚点。这样后人查 git log 时能立刻定位代码变更和决策文档之间的关系。
Q:ADR 被推翻怎么办?删除原文件?
不删除。编辑原 ADR 把状态改成 Superseded by NNNN,新 ADR 的 Supersedes 字段反向链接。这样形成可追溯的演进链。
0007 — Use Apicurio Schema Registry for events [Status: Accepted]
(Supersedes 0004)
0004 — Per-event SCHEMA_VERSION constants [Status: Superseded by 0007]
(Date: 2026-04-25; this approach worked at 4 events but doesn't scale beyond ~10)
ID 在约定中不变。新人读到 0004 看到 superseded 标记,立刻知道现状是 0007,但仍然可以读 0004 学到"为什么单纯加版本号最终不够用"。
四、Service Catalog:让依赖图机器可读#
Q:Service Catalog 解决什么?ADR 已经记录了决策,还需要它干什么?
在我的实践中,ADR 更偏向解决架构问题。Service Catalog 则更偏向运维问题——
- order-service 出 5xx,告警分给谁?
- buyer-bff 调用了哪些后端?哪个挂了影响最大?
- 一个新接口要 review,谁是它消费方?
- 谁负责 wallet-service?
这类问题的答案适合机器可读的格式。Backstage 的 catalog-info.yaml 是目前业界较为广泛使用的格式之一,即使不部署 Backstage,YAML 本身也可以作为结构化的"谁拥有什么、谁调用什么"文档。
Q:怎么布局?
catalog-info.yaml # root:Domain + System
services/
api-gateway/catalog-info.yaml # Component
auth-server/catalog-info.yaml # Component + API
buyer-bff/catalog-info.yaml # Component + API(含 OpenAPI 引用)
order-service/catalog-info.yaml # Component + dependsOn (Resource)
...
每个服务一个 catalog-info.yaml,跟 pom.xml 同级。
Q:最小有效字段是什么?
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: order-service # kebab-case,等于 artifactId
title: Order Service
description: | # 一段话:技术栈 + 作用
Domain service owning the order aggregate,
cart, and checkout state machine. Publishes
OrderEventData (v1) via the transactional outbox.
annotations:
github.com/project-slug: meirongdev/shop
backstage.io/source-location: url:https://github.com/meirongdev/shop/tree/main/services/order-service
grafana/dashboard-selector: "tags @> 'order-service'"
prometheus.io/rule: shop:order_errors:ratio_rate5m
tags: [java, spring-boot, kafka-producer, outbox]
spec:
type: service # 或 library / website
lifecycle: production # 或 experimental / deprecated
owner: order-team # ← 关键:与 Prometheus alert owner 一致
system: shop-platform
providesApis: [order-api]
consumesApis: [marketplace-api, wallet-api]
dependsOn:
- resource:order-mysql
- resource:kafka-cluster
Q:API 怎么声明?
---
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
name: buyer-bff-api
title: Buyer BFF API
description: |
Public buyer-facing HTTP API. Versioned at /api/buyer/v1.
OpenAPI spec is generated by `make generate-bff-spec`.
spec:
type: openapi
lifecycle: production
owner: buyer-team
system: shop-platform
definition:
$text: ./src/main/resources/openapi/buyer-api.json
definition.$text 引用本仓库 OpenAPI 文件——同一个 YAML 同时是服务目录和 API 浏览器入口。
五、关键约定:owner 必须三处对齐#
Q:catalog-info.yaml 和告警系统的关系?
spec.owner 字段我会尽量要求满足三处对齐:
catalog-info.yaml的spec.owner: order-team- Prometheus alert rule 的
labels.owner: order-team - SLO recording rule 关联的服务标识
任意一处不一致,告警分发就可能去错团队。这也是 service catalog 在运维侧最实用的一点——尽量把所有权收敛成单一真相来源。
shop-platform 把这条约定写进了 docs/SERVICE-CATALOG.md:
spec.owner必须与 Prometheus 告警规则中的owner标签一致(见platform/k8s/observability/prometheus/alert-rules.yaml、slo-rules.yaml)。这是单一真相来源约束 —— 一旦不一致,告警分发会去错团队。
六、不部署 Backstage 也有价值#
Q:必须装 Backstage 才能用 catalog-info.yaml 吗?
不必。catalog-info.yaml 本身就是合法的、结构化的、可 grep 的文档:
# 谁 owns auth-server?
yq '.spec.owner' services/auth-server/catalog-info.yaml
# buyer-bff 消费哪些 API?
yq '.spec.consumesApis' services/buyer-bff/catalog-info.yaml
# 列出所有 production lifecycle 的服务
grep -l "lifecycle: production" services/*/catalog-info.yaml
PR 改动 catalog-info.yaml 走代码 review,不一致问题在 review 阶段就发现。一切都不依赖 Backstage 部署。
真要装 Backstage 时,只需在 app-config.yaml 里显式列出实际存在的 catalog 文件地址;我这里用本地文件路径举例,避免把 * 通配写成一个点不开的示例 URL:
catalog:
locations:
- type: file
target: ../../catalog-info.yaml
- type: file
target: ../../services/order-service/catalog-info.yaml
- type: file
target: ../../services/buyer-bff/catalog-info.yaml
rules:
- allow: [Component, API]
之后所有 PR 改动自动同步到 Backstage UI。
七、绕不开的 tradeoff#
Q:ADR 听起来负担大,团队真的会写吗?
根据我的观察和实践,ADR 系统失败的常见原因往往源于"过度热情":
- 写得太多。给所有变更都写 ADR(连依赖版本升级都写一份),三个月后没人读、新人当成噪声跳过。我见过的例子里,每月超过 5 份 ADR 的项目,往往会逐渐退化。
- 写得太长。把 ADR 当 design doc 写,每份 3000 字。结果是写的人累、读的人累、决策密度低。目标:每份 200-500 字读完。
- 要求审批。把 ADR 变成"需要架构师签字"的流程,工程师就会绕过它。ADR 应该跟 PR 一样普通——
Proposed状态写完发出来 review,社区反馈完改成Accepted。
我自己更能接受的"产出节奏"大概是:
- Year 1(项目早期):4-8 份 ADR(关键决策)
- 之后每年:8-12 份
- 超过这个量级,说明你在写不该写的东西。
Q:Service Catalog 的最大坑?
陈旧风险。catalog-info.yaml 在 PR 里好好的,但服务实际拓扑变了——比如新加了一个 downstream API 调用——开发者忘了改 catalog。
防护手段两层:
- 依赖图自动校验(在 CI 里 grep):
consumesApis列出的 API 必须真的存在某个providesApis: [<api-name>]的服务。否则 CI 失败。 - owner 一致性检查:catalog-info.yaml 的
spec.owner与 Prometheus alert rules 的labels.owner必须能查到同一个 group 实体。一致性脚本跑在 CI。
这些校验本身写起来不复杂(几十行 Python 或 shell),但是落地的关键。否则三个月后你的 service catalog 跟现实严重脱钩,比没有还糟。
Q:什么场景下不应该急着上?
- 服务数 < 5 且团队 < 10 人——
README.md加一段"谁负责什么"够用 - 还在 PoC / MVP 阶段,服务边界本身还在快速变——花两小时写 catalog 不如花两小时讨论拓扑
但对稳定运行一段时间、服务数已经明显上来的平台来说,缺 catalog 往往会慢慢变成技术债,最先体现在入职和排障成本上。
八、最小落地清单#
按依赖顺序:
-
建 docs/adr/(投入:30 分钟)
README.md:进程说明、何时写、命名规范template.md:MADR 0.4.0 模板0001-record-architecture-decisions.md:meta-ADR
-
写 2-3 份 retrospective ADR(投入:每份 30 分钟)
- 选最近 6 个月里"最容易被新人质疑"的 3 个决策
- 引用具体 commit hash
-
建 root catalog-info.yaml(投入:5 分钟)
- Domain + System,定义所有权根
-
给 3-4 个代表性服务写 catalog-info.yaml(投入:每个 10 分钟)
- 选不同类型:gateway / auth / BFF / domain service
- 让
providesApis/consumesApis形成依赖图样例
-
写 SERVICE-CATALOG.md 约定文档(投入:30 分钟)
- 字段约定
- owner 对齐要求
- 当前覆盖度表(让剩余服务靠"复制模板"补齐)
-
三处对齐检查脚本进 CI(投入:1 小时)
- catalog owner ↔ alert owner ↔ SLO owner
这套基线大约需要一个工作日。按我的经验,回报通常能比较快看到:新人入职会更容易找到服务边界,post-mortem 里"出错 service 的归属团队是谁"也不必再反复翻 Slack。