《后端架构设计·架构基础与边界》9.3 架构治理的最小流程

以 arch-lab 的最小治理流程为例,只保留 ADR、例外、季度复审和三个指标四个动作,并把规则写成可运行的 govcheck 校验器;表驱动测试覆盖 ADR 不完整、复审过期、替代失效、缺适应度测试、例外超期与指标越线,附真实运行输出。

治理不是制度汇编

很多团队的架构治理最后变成一本制度汇编:审批流程三十页,模板十二个,半年后没人记得哪条还在用。问题不是制度太少,而是制度不可执行:每条规则都靠人记,记不住就靠开会,开会又产生新的例外,最后例外比规则还多。

治理的成本不在写规则,而在每条规则都要有人记、有人查、有人对结果负责。只要其中一环缺失,规则就会退化成墙上的海报。最小流程的判断标准很直接:每个动作都有字段、有频率、有检查;做不到就删掉,不要留在文档里占位置。

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_coverageUP100%100%
exception_aging_daysDOWN≤30 天21 天
decision_lead_time_daysDOWN≤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 认证、授权与服务间身份 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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