治理不是制度汇编
很多团队的架构治理最后变成一本制度汇编:审批流程三十页,模板十二个,半年后没人记得哪条还在用。问题不是制度太少,而是制度不可执行:每条规则都靠人记,记不住就靠开会,开会又产生新的例外,最后例外比规则还多。
治理的成本不在写规则,而在每条规则都要有人记、有人查、有人对结果负责。只要其中一环缺失,规则就会退化成墙上的海报。最小流程的判断标准很直接:每个动作都有字段、有频率、有检查;做不到就删掉,不要留在文档里占位置。
arch-lab 的最小治理只保留四个动作:决策留痕(ADR)、例外有期限、季度复审、三个指标。四件事分别对应四类可检查的数据,最终落到一个 govcheck 包里。它不是制度汇编的电子版,而是把“治理规则”翻译成 CI 能跑的检查项。
最小流程的四个动作
| 治理对象 | 最小字段 | 检查点 | 频率 |
|---|---|---|---|
| ADR | 状态、备选、后果、复审日、适应度测试 | 接受前完整,到期必复审 | 每次决策 |
| 例外 | owner、原因、补偿控制、到期日 | 不超过 90 天,同一 owner ≤2 条 | 每周 |
| 复审 | 过期 ADR、活跃例外 | 只处理这两类 | 每季度 |
| 指标 | owner、窗口、目标、方向 | 越线告警 | 每月 |
这张表刻意没有“审批人”“评审委员会”“架构门禁”这几列。治理要解决的是决策是否留痕、例外是否收敛,不是给每个 PR 再加一道签字。签字越多,绕过越多;绕过不留痕,治理就失去数据。
ADR:只写会改变边界的决策
ADR 只写会改变边界、数据所有权、跨团队契约或运行成本的决策;本地重构、命名调整、日志格式不写。“什么都要写 ADR”和“什么都不写”一样糟:前者制造大量无人复审的文档,后者让关键决策只剩聊天记录。
一条被接受的 ADR 至少要有两个备选和一段后果。只有一个方案,说明没有比较;没有后果,说明没有推演。2.2 已经给过 arch-lab 的 ADR 模板,本节只加两个字段:复审日和适应度测试。复审日到了没有复审,状态应视为过期,而不是默认继续有效;SUPERSEDED 必须指向一条已接受的替代 ADR,否则“已被替代”只是口头说法。
边界类 ADR 还必须引用一个可执行检查。没有检查的边界只是一句宣言,下一个人可以礼貌地同意,然后继续跨包 import。检查可以是依赖测试、契约测试或一条静态规则;形式不限,但必须能在 CI 里跑。
例外:必须有到期日
例外是治理的泄压阀。没有例外流程,规则会被绕过,而且绕过不会留痕;有了例外流程,至少能知道哪条规则正在被违反、由谁承担、准备怎么补。
每条例外要写清五件事:违反哪条规则、谁负责、为什么、补偿控制是什么、什么时候到期。上限 90 天,同一个 owner 同时不超过 2 条。超过 2 条通常不是纪律问题,而是排期或规则本身有问题:要么人力不够,要么这条规则根本不适用。
到期只有两个出口:修好并关闭,或者重新申请并说明上一个补偿控制为什么没生效。不能自动续期,也不能把“正在处理”当成新状态。例外一旦可以无限续,它就不再是例外,而是事实上的规则。
复审:季度一次,只看两件事
复审每季度一次,30 到 60 分钟,只看两件事:过期 ADR 和活跃例外。不做逐 PR 审批,不开集中评审队列,也不重新讨论已经决出的方案。复审只检查前提是否还成立:当时的约束还在吗,替代方案还有效吗,补偿控制真的在执行吗。
复审的输出是三个动作:关闭、续期、升级为 ADR。关闭要有证据,续期要有新的到期日和理由,升级要把口头例外变成正式决策。如果一次复审没有产生任何关闭或续期,要么治理已经稳定,要么数据没更新;这两个结论要靠指标区分,不能靠感觉。
指标:三个就够
指标多了会变成报表工程。arch-lab 只保留三个:ADR 覆盖率(边界决策中有 ADR 的比例)、例外年龄(活跃例外的平均天数)、决策周期(从提出到接受的中位天数)。每个指标必须有 owner、统计窗口、目标和方向;没有 owner 的指标没人看,没有窗口的指标口径会打架。
| 指标 | 方向 | 目标 | 基线 |
|---|---|---|---|
| adr_coverage | UP | 100% | 100% |
| exception_aging_days | DOWN | ≤30 天 | 21 天 |
| decision_lead_time_days | DOWN | ≤5 天 | 3.5 天 |
指标用于发现问题,不用于团队排名。一旦排名,ADR 会在事后补,例外会拆成多条,决策周期会通过提前开会来“优化”。目标值应该来自团队自己的历史,而不是抄行业数字;窗口统一为 30 天,避免月初月末两套口径。
把规则写成校验器
govcheck 把 ADR、例外、指标放进一个 Corpus,Validate(now) 是纯函数:now 由调用方传入,测试固定在 2026-10-01,生产环境传当前时间并在报告里记录运行时刻。这样同一份数据永远得到同样的违规,CI 不会因为跨天而变红。
// file: internal/govcheck/govcheck.go
// tier: L1
// verify: go test ./internal/govcheck/
package govcheck
import (
"fmt"
"time"
)
type ADR struct {
ID string
Status string // PROPOSED, ACCEPTED, SUPERSEDED, REJECTED
ReviewAt time.Time
Boundary bool
Alternatives []string
Consequences string
SupersededBy string
FitnessTest string
}
type Exception struct {
ID string
Rule string
Owner string
Reason string
ExpiresAt time.Time
Compensating string
Status string // ACTIVE, CLOSED
}
type Metric struct {
Name string
Owner string
Direction string // UP: higher is better, DOWN: lower is better
Target float64
Current float64
WindowDays int
}
type Corpus struct {
ADRs []ADR
Exceptions []Exception
Metrics []Metric
}
type Finding struct {
Code string
Subject string
Detail string
}
// Validate 把最小治理流程里的四条规则变成可检查项:ADR 完整性、
// 例外有效期与数量、指标是否越线、以及边界决策是否有适应度测试。
func (c Corpus) Validate(now time.Time) []Finding {
var out []Finding
seen := make(map[string]bool, 16)
add := func(code, subject, detail string) {
key := code + "\x00" + subject
if seen[key] {
return
}
seen[key] = true
out = append(out, Finding{Code: code, Subject: subject, Detail: detail})
}
adrs := make(map[string]ADR, len(c.ADRs))
for _, a := range c.ADRs {
if _, dup := adrs[a.ID]; dup {
add("DUPLICATE_ADR", a.ID, "adr id declared twice")
}
adrs[a.ID] = a
}
for _, a := range c.ADRs {
switch a.Status {
case "ACCEPTED":
if len(a.Alternatives) < 2 || a.Consequences == "" {
add("ADR_INCOMPLETE", a.ID, "accepted ADR needs two alternatives and consequences")
}
if a.ReviewAt.Before(now) {
add("ADR_REVIEW_OVERDUE", a.ID, a.ReviewAt.Format("2006-01-02"))
}
case "SUPERSEDED":
next, ok := adrs[a.SupersededBy]
if !ok || next.Status != "ACCEPTED" {
add("BAD_SUPERSEDE", a.ID, a.SupersededBy)
}
}
if a.Boundary && a.FitnessTest == "" {
add("MISSING_FITNESS_TEST", a.ID, "boundary decision needs an executable check")
}
}
active := make(map[string]int, len(c.Exceptions))
for _, e := range c.Exceptions {
if e.Status != "ACTIVE" {
continue
}
active[e.Owner]++
if e.Owner == "" || e.Reason == "" || e.Compensating == "" {
add("EXCEPTION_INCOMPLETE", e.ID, "owner, reason and compensating control are required")
}
switch {
case !e.ExpiresAt.After(now):
add("EXCEPTION_EXPIRED", e.ID, e.ExpiresAt.Format("2006-01-02"))
case e.ExpiresAt.After(now.Add(90 * 24 * time.Hour)):
add("EXCEPTION_TOO_LONG", e.ID, e.ExpiresAt.Format("2006-01-02"))
}
}
for _, e := range c.Exceptions {
if e.Status == "ACTIVE" && active[e.Owner] > 2 {
add("EXCEPTION_OVERLOAD", e.Owner, fmt.Sprintf("%d active exceptions", active[e.Owner]))
}
}
for _, m := range c.Metrics {
if m.Owner == "" || m.WindowDays <= 0 || m.Target <= 0 ||
(m.Direction != "UP" && m.Direction != "DOWN") {
add("METRIC_INCOMPLETE", m.Name, "owner, window, target and direction are required")
continue
}
breach := (m.Direction == "UP" && m.Current < m.Target) ||
(m.Direction == "DOWN" && m.Current > m.Target)
if breach {
add("METRIC_BREACH", m.Name, fmt.Sprintf("current=%.2f target=%.2f", m.Current, m.Target))
}
}
return out
}
它检查记录是否完整、是否过期、是否越线,不判断决策质量。一条 ADR 可能备选写得很好、后果也完整,但选的方案仍然是错的;校验器抓不到这种错,也不应该假装能抓到。它能做的是让“没写备选”“复审过期”“例外超期”这些事实无法被忽略。
测试:八个变异
基线是三条 ADR、两条例外、三个指标,零违规。八个变异分别模拟 ADR 缺备选、复审过期、替代 ADR 不存在、边界决策缺适应度测试、例外已过期、例外超过 90 天、指标越线、同一 owner 例外过多。
// file: internal/govcheck/govcheck_test.go
// tier: L1
// verify: go test ./internal/govcheck/
package govcheck
import (
"testing"
"time"
)
var testNow = time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
func baseline() Corpus {
return Corpus{
ADRs: []ADR{
{ID: "ADR-001", Status: "ACCEPTED", ReviewAt: testNow.AddDate(0, 3, 14), Boundary: true,
Alternatives: []string{"modular monolith", "microservices"}, Consequences: "team autonomy with one deploy unit",
FitnessTest: "TestNoCrossContextImport"},
{ID: "ADR-002", Status: "ACCEPTED", ReviewAt: testNow.AddDate(0, 2, 0),
Alternatives: []string{"outbox", "dual write"}, Consequences: "at-least-once delivery, replay tooling required"},
{ID: "ADR-003", Status: "SUPERSEDED", SupersededBy: "ADR-002"},
},
Exceptions: []Exception{
{ID: "EX-001", Rule: "no-sync-call-between-contexts", Owner: "team-order", Reason: "legacy refund path",
ExpiresAt: testNow.AddDate(0, 0, 30), Compensating: "timeout and circuit breaker", Status: "ACTIVE"},
{ID: "EX-002", Rule: "adr-before-code", Owner: "team-catalog", Reason: "hotfix", ExpiresAt: testNow.AddDate(0, 0, 10),
Compensating: "retro ADR within 5 days", Status: "CLOSED"},
},
Metrics: []Metric{
{Name: "adr_coverage", Owner: "team-arch", Direction: "UP", Target: 100, Current: 100, WindowDays: 30},
{Name: "exception_aging_days", Owner: "team-arch", Direction: "DOWN", Target: 30, Current: 21, WindowDays: 30},
{Name: "decision_lead_time_days", Owner: "team-arch", Direction: "DOWN", Target: 5, Current: 3.5, WindowDays: 30},
},
}
}
func TestBaselineReport(t *testing.T) {
c := baseline()
findings := c.Validate(testNow)
t.Logf("adrs=%d exceptions=%d metrics=%d findings=%d", len(c.ADRs), len(c.Exceptions), len(c.Metrics), len(findings))
if len(findings) != 0 {
t.Fatalf("baseline should be clean, got %v", findings)
}
}
func TestValidateCatchesGovernanceRot(t *testing.T) {
cases := []struct {
name string
mutate func(*Corpus)
code string
subject string
}{
{"adr without alternatives", func(c *Corpus) {
c.ADRs[0].Alternatives = []string{"only option"}
}, "ADR_INCOMPLETE", "ADR-001"},
{"adr review overdue", func(c *Corpus) {
c.ADRs[0].ReviewAt = testNow.Add(-24 * time.Hour)
}, "ADR_REVIEW_OVERDUE", "ADR-001"},
{"superseded by missing adr", func(c *Corpus) {
c.ADRs[2].SupersededBy = "ADR-999"
}, "BAD_SUPERSEDE", "ADR-003"},
{"boundary decision without test", func(c *Corpus) {
c.ADRs[0].FitnessTest = ""
}, "MISSING_FITNESS_TEST", "ADR-001"},
{"exception already expired", func(c *Corpus) {
c.Exceptions[0].ExpiresAt = testNow.Add(-24 * time.Hour)
}, "EXCEPTION_EXPIRED", "EX-001"},
{"exception lasts too long", func(c *Corpus) {
c.Exceptions[0].ExpiresAt = testNow.AddDate(0, 4, 0)
}, "EXCEPTION_TOO_LONG", "EX-001"},
{"metric below target", func(c *Corpus) {
c.Metrics[0].Current = 80
}, "METRIC_BREACH", "adr_coverage"},
{"too many active exceptions", func(c *Corpus) {
for _, id := range []string{"EX-003", "EX-004"} {
c.Exceptions = append(c.Exceptions, Exception{ID: id, Owner: "team-order", Reason: "migration", Status: "ACTIVE",
ExpiresAt: testNow.AddDate(0, 0, 20), Compensating: "feature flag"})
}
}, "EXCEPTION_OVERLOAD", "team-order"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := baseline()
tc.mutate(&c)
if !has(c.Validate(testNow), tc.code, tc.subject) {
t.Fatalf("want %s on %q, got %v", tc.code, tc.subject, c.Validate(testNow))
}
})
}
}
func has(findings []Finding, code, subject string) bool {
for _, f := range findings {
if f.Code == code && f.Subject == subject {
return true
}
}
return false
}
实测输出
$ go test ./internal/govcheck/ -run TestBaselineReport -v
=== RUN TestBaselineReport
govcheck_test.go:37: adrs=3 exceptions=2 metrics=3 findings=0
--- PASS: TestBaselineReport (0.00s)
PASS
ok archlab/internal/govcheck 0.361s
基线零违规;完整测试的八个变异全部被抓住,gofmt -l 无输出,go vet ./... 无告警。这些数字也是治理基线:ADR 增加而复审日期没增加,说明只写不复审;例外关闭数量长期为零,说明例外只进不出;决策周期突然变短,可能只是把提出时间改到了开会之后。
运行方式与分级
建议每周一在 CI 跑一次,发布前再跑一次;报告存到制品库或 .local/reports/,保留 90 天。校验器本身不阻断构建,阻断策略放在流水线:前两周全部按 WARN 处理,收集数据;稳定后把 EXCEPTION_EXPIRED 和 METRIC_BREACH 设为 BLOCKER,ADR_REVIEW_OVERDUE 保持 WARN。
BLOCKER 只拦发布,不拦合并。治理不应该成为日常开发的红灯;如果每次提交都要等架构审批,团队会绕开流程,最后连 WARN 都没人看。反过来,过期例外和指标越线必须有人停下来处理,否则治理就只剩下报告。
不做的三件事也要写清楚:不做每个 PR 的架构审批,不开集中评审队列,不按团队排名治理指标。这三件事都会把治理从“降低不确定性”变成“制造工作量”,而工作量是可以通过形式主义消化的。
常见错误与边界
第一,把治理等同于审批。审批只能证明有人签字,不能证明决策被理解;留痕、复审和指标才回答“这个决定后来怎么样了”。第二,ADR 写成会议纪要。没有备选、后果和复审日,它就只是一份记录,不是决策。
第三,例外永久化。到期自动续、续期不写理由,例外就会变成事实规则,而且没人敢删。第四,指标太多或用于考核。指标一多就没人看,一用于排名就会被优化数字,而不是优化问题。
第五,校验器只跑一次。治理数据会过期,成员、契约、边界都在变;每周跑一次是最低频率。边界也要说清楚:法务、合规、安全审计有各自的流程,不能塞进这套模型;跨公司协作的决策权也不由本团队的矩阵决定。校验器只保证记录完整,不保证决策正确。
小结
最小治理流程不是制度汇编,而是四个可执行的动作:ADR 留痕、例外有到期日、季度复审、三个指标。arch-lab 用 internal/govcheck 把这些动作变成纯函数检查:ADR 缺备选、复审过期、替代失效、边界缺测试、例外超期、指标越线都无法悄悄通过。基线零违规、八个变异全部被抓住;治理的下一步不是加更多规则,而是让这四条规则在每周的流水线里真正运行。
阅读导航:上一节:9.2 架构决策权与团队契约 · 下一节:10.1 认证、授权与服务间身份 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。