《后端架构设计·架构基础与边界》13.1 从需求到架构决策记录

把卷一散落在各章的决策收敛成一条可检查的链:需求与约束先变成驱动因素,七条 ADR 再按依赖关系排序;给出带 drivers、depends_on、备选与否决理由、正负后果的 Go 索引结构,并用校验器捕获孤立 ADR、缺失备选、无后果、非法日期与依赖环。

从风险清单到决策链

12.3 留下的是一张风险清单:超卖、单库可用性、outbox 积压、开关失控。清单能提示危险,却不能回答后来者最常问的那句话:当时为什么选这条路,又否决了什么。风险条目描述的是“可能出什么事”,决策记录描述的才是“我们决定怎么应对、愿意付什么代价”。本节的交付物不是再写七份 ADR,而是把卷一已经发生的决策整理成一条可检查的链:需求与约束变成驱动因素,驱动因素约束决策,决策留下正负后果,决策之间再用依赖关系排序;链上的每一环都要能指向具体证据,指向不了的就诚实标成未知。

需求先变成可检验的驱动因素

需求不能直接写成“要高性能”。它必须先落到可观察的约束上,才能成为某个 ADR 的 driver。下表把 12.1 的需求和 12.3 的场景压缩成六条驱动;其中“峰值未测”是刻意保留的未知项,因为它决定了哪些结论现在不能下。

需求或约束证据状态驱动因素约束了哪些决策
两人团队,没有独立 DBA团队事实运维简单、决策可逆ADR-001、ADR-003、ADR-007
日订单不到万级,峰值未测12.1 的未知项暂不把吞吐当首要约束ADR-003、ADR-004
任何情况下不得超卖业务硬规则库存与订单强一致ADR-002、ADR-003
支付回调 500ms 内返回12.3 的质量场景支付后异步推进ADR-004、ADR-006
进程崩溃不能丢领域事件2.3 的评审条件至少一次投递且可重放ADR-006
未来可能按模块拆分1.3 的重评触发条件边界可演化、依赖单向ADR-001、ADR-005

这张表最重要的不是六行结论,而是第二列。把“峰值未测”写成“峰值一万”会让 ADR-003 看起来已经被数据支持,实际上它只是被当前规模容忍。驱动因素一旦伪造,后面的依赖链会一起失真。

七条决策不是并列的

把七条 ADR 按编号排成目录,会掩盖它们之间的先后关系。ADR-006 不是在真空中选了 outbox,它同时被 ADR-003 的单库事务和 ADR-004 的异步履约约束;如果先推翻 004,006 的备选比较要重做。

ADR-001 模块化单体
  ├── ADR-002 下单同步预留库存
  │     └── ADR-003 PostgreSQL 单写库
  │           └── ADR-004 下单同步、履约异步
  │                 └── ADR-006 领域事件写入 outbox
  ├── ADR-005 模块间只经端口通信
  │     └── ADR-007 单部署单元与模块开关

主链是 001 → 002 → 003 → 004 → 006。它说明 outbox 是这条链的末端结论,不是可以单独替换的中间件选型;想换掉 006,必须先回答 003 是否仍是单写库、004 是否仍允许支付后异步。另一条支线 001 → 005 → 007 与主链并行,所以灰度开关可以先做,但它依赖端口边界,不能反过来“先上开关再补端口”。

依赖关系还暴露了承重墙。ADR-003 被 004 和 006 同时依赖,它的重评触发条件(持续写入超过 5k QPS、主库切换 RTO 超过 5 分钟)一旦命中,受影响的不止存储选型,还包括履约时序和事件投递方式。索引的价值就在这里:它让“改一条 ADR”变成一次可计算的影响分析,而不是凭记忆判断。

索引只存可校验的事实

ADR 正文回答“为什么”,索引只存评审程序能读的骨架。下表是本节采用的字段,以及每个字段要拦住的事故。

字段为什么必须对应校验
id稳定引用,不能靠标题格式与重复
status区分已接受、提议、被取代枚举合法性
date决策发生时间,不是文章发布时间RFC3339 解析
drivers决策必须挂在需求上不能为空
depends_on决策之间的先后约束悬空引用与环
alternatives至少两个被认真比较的候选数量与否决理由
consequences同时记录收益和代价正负都不能缺
superseded_by被取代时指向继任者状态与目标一致

字段少是刻意的。索引不复制论证、不保存会议纪要,也不记录 owner 和审批流;那些内容变化快,适合留在 ADR 正文或协作工具里。索引只保留“改一条会不会破坏另一条”的结构信息。

校验器:把文档事故变成测试失败

下面这个包把索引变成可执行结构。它检查十二类问题:编号格式、重复编号、非法状态、非法日期、缺少驱动、备选不足两个、备选没有否决理由、后果缺少正面或负面、悬空依赖、依赖环、孤立 ADR,以及 superseded 状态与目标不一致。

// file: internal/adrindex/index.go
// tier: L1
// verify: go test ./internal/adrindex/ -count=1 -v
package adrindex

import (
	"regexp"
	"strings"
	"time"
)

type Status string

const (
	StatusProposed   Status = "proposed"
	StatusAccepted   Status = "accepted"
	StatusSuperseded Status = "superseded"
	StatusRejected   Status = "rejected"
)

func (s Status) Valid() bool {
	switch s {
	case StatusProposed, StatusAccepted, StatusSuperseded, StatusRejected:
		return true
	}
	return false
}

type Alternative struct {
	Name   string
	Reason string
}
type Consequences struct {
	Positive []string
	Negative []string
}
type Entry struct {
	ID           string
	Title        string
	Status       Status
	Date         string
	Drivers      []string
	DependsOn    []string
	Alternatives []Alternative
	Consequences Consequences
	SupersededBy string
}
type Index struct{ Entries []Entry }
type Issue struct {
	Code, ID, Detail string
}

const (
	CodeInvalidID           = "INVALID_ID"
	CodeDuplicateID         = "DUPLICATE_ID"
	CodeInvalidStatus       = "INVALID_STATUS"
	CodeInvalidDate         = "INVALID_DATE"
	CodeNoDriver            = "NO_DRIVER"
	CodeNoAlternative       = "NO_ALTERNATIVE"
	CodeAlternativeNoReason = "ALTERNATIVE_NO_REASON"
	CodeNoConsequence       = "NO_CONSEQUENCE"
	CodeDanglingDependency  = "DANGLING_DEPENDENCY"
	CodeDependencyCycle     = "DEPENDENCY_CYCLE"
	CodeIsolatedADR         = "ISOLATED_ADR"
	CodeSupersededMismatch  = "SUPERSEDED_MISMATCH"
)

var idPattern = regexp.MustCompile(`^ADR-\d{3}$`)

// Validate 只检查索引自洽,不判断决策内容是否正确。
func (in Index) Validate() []Issue {
	var issues []Issue
	seen := make(map[string]bool, len(in.Entries))
	for _, e := range in.Entries {
		if !idPattern.MatchString(e.ID) {
			issues = append(issues, Issue{CodeInvalidID, e.ID, "want ADR-NNN"})
		}
		if seen[e.ID] {
			issues = append(issues, Issue{CodeDuplicateID, e.ID, "declared twice"})
		}
		seen[e.ID] = true
		if !e.Status.Valid() {
			issues = append(issues, Issue{CodeInvalidStatus, e.ID, string(e.Status)})
		}
		if _, err := time.Parse(time.RFC3339, e.Date); err != nil {
			issues = append(issues, Issue{CodeInvalidDate, e.ID, e.Date})
		}
		if len(e.Drivers) == 0 {
			issues = append(issues, Issue{CodeNoDriver, e.ID, "no requirement driver"})
		}
		if len(e.Alternatives) < 2 {
			issues = append(issues, Issue{CodeNoAlternative, e.ID, "need two candidates"})
		}
		for _, alt := range e.Alternatives {
			if strings.TrimSpace(alt.Reason) == "" {
				issues = append(issues, Issue{CodeAlternativeNoReason, e.ID, alt.Name})
			}
		}
		if len(e.Consequences.Positive) == 0 || len(e.Consequences.Negative) == 0 {
			issues = append(issues, Issue{CodeNoConsequence, e.ID, "need both signs"})
		}
		if (e.Status == StatusSuperseded) != (e.SupersededBy != "") {
			issues = append(issues, Issue{CodeSupersededMismatch, e.ID, "status/target mismatch"})
		}
	}

	dependents := make(map[string]bool, len(in.Entries))
	for _, e := range in.Entries {
		for _, dep := range e.DependsOn {
			if !seen[dep] {
				issues = append(issues, Issue{CodeDanglingDependency, e.ID, dep})
				continue
			}
			dependents[dep] = true
		}
	}
	for _, e := range in.Entries {
		if len(e.DependsOn) == 0 && !dependents[e.ID] {
			issues = append(issues, Issue{CodeIsolatedADR, e.ID, "no edge in or out"})
		}
	}
	if in.hasCycle() {
		issues = append(issues, Issue{CodeDependencyCycle, "", "DependsOn contains a cycle"})
	}
	return issues
}

func (in Index) hasCycle() bool {
	graph := make(map[string][]string, len(in.Entries))
	for _, e := range in.Entries {
		graph[e.ID] = append(graph[e.ID], e.DependsOn...)
	}
	state := make(map[string]int, len(in.Entries))
	var visit func(string) bool
	visit = func(id string) bool {
		if state[id] == 1 {
			return true
		}
		if state[id] == 2 {
			return false
		}
		state[id] = 1
		for _, dep := range graph[id] {
			if visit(dep) {
				return true
			}
		}
		state[id] = 2
		return false
	}
	for id := range graph {
		if visit(id) {
			return true
		}
	}
	return false
}

孤立 ADR 的定义值得单独说明:一条记录既没有 depends_on,也没有任何其他记录依赖它,就说明它没有接入决策网络。它可能是刚写的提议,也可能是被遗忘的旧文档;无论哪种,评审时都应该被看见。依赖环则用深度优先搜索的着色法检测,命中灰节点即存在回边。

卷一登记表

登记表是 2026-10-06 评审前的快照:ADR-001 到 005 已接受,006 和 007 仍是提议。每条都保留两个被否决的候选方案和正负后果,否则校验器会直接报错。

// file: internal/adrindex/volume_one.go
// tier: L1
// verify: go test ./internal/adrindex/ -run TestVolumeOneIndex -v
package adrindex

// VolumeOne 是卷一七条决策的索引;正文论证仍留在各 ADR 文档里。
func VolumeOne() Index {
	return Index{Entries: []Entry{
		{ID: "ADR-001", Title: "模块化单体作为卷一形态", Status: StatusAccepted, Date: "2026-10-05T10:00:00+08:00", Drivers: []string{"REQ-01", "REQ-02"},
			Alternatives: []Alternative{{"按模块拆成四个微服务", "两人团队承担不起跨服务事务、发布和监控成本"}, {"纯分层大泥球", "没有模块边界,后续无法增量拆分"}},
			Consequences: Consequences{Positive: []string{"单事务、单部署、测试与运维成本最低"}, Negative: []string{"边界只能靠包约束和静态检查"}}},
		{ID: "ADR-002", Title: "创建订单时同步预留库存", Status: StatusAccepted, Date: "2026-10-06T09:00:00+08:00", Drivers: []string{"REQ-03"}, DependsOn: []string{"ADR-001"},
			Alternatives: []Alternative{{"下单后异步扣减库存", "用户可见路径出现超卖窗口"}, {"支付成功后再预留", "支付前库存可能已被其他订单占用"}},
			Consequences: Consequences{Positive: []string{"超卖在同步路径被挡住"}, Negative: []string{"下单延迟包含库存调用,库存故障会阻塞下单"}}},
		{ID: "ADR-003", Title: "订单主存储选用 PostgreSQL 单写库", Status: StatusAccepted, Date: "2026-10-06T10:00:00+08:00", Drivers: []string{"REQ-03", "REQ-06"}, DependsOn: []string{"ADR-001", "ADR-002"},
			Alternatives: []Alternative{{"内存存储作为生产主库", "重启丢单,无法多进程共享和审计"}, {"文档库存订单快照", "跨订单、库存、outbox 的事务与查询能力不足"}},
			Consequences: Consequences{Positive: []string{"订单与库存同事务提交,备份恢复路径单一"}, Negative: []string{"写吞吐受单主库限制,数据库成为下单可用性上限"}}},
		{ID: "ADR-004", Title: "下单同步、履约异步", Status: StatusAccepted, Date: "2026-10-06T11:00:00+08:00", Drivers: []string{"REQ-04"}, DependsOn: []string{"ADR-002", "ADR-003"},
			Alternatives: []Alternative{{"支付回调内同步履约", "回调延迟绑定仓库系统,重试会重复发货"}, {"连库存预留也异步", "把超卖窗口留给用户可见路径"}},
			Consequences: Consequences{Positive: []string{"支付回调快,履约故障可重试"}, Negative: []string{"PAID 与 FULFILLED 之间存在时间窗,消费者必须幂等"}}},
		{ID: "ADR-005", Title: "模块间只经端口接口通信", Status: StatusAccepted, Date: "2026-10-06T12:00:00+08:00", Drivers: []string{"REQ-05"}, DependsOn: []string{"ADR-001"},
			Alternatives: []Alternative{{"共享一个 repository 包", "改一张表波及所有模块,测试必须连库"}, {"允许跨模块直接 SQL join", "短期快,长期绕过不变量和归属校验"}},
			Consequences: Consequences{Positive: []string{"模块可独立测试、替换实现,未来可拆分"}, Negative: []string{"接口数量增加,跨模块事务需要显式 unit-of-work"}}},
		{ID: "ADR-006", Title: "领域事件写入 outbox", Status: StatusProposed, Date: "2026-10-06T14:00:00+08:00", Drivers: []string{"REQ-04", "REQ-05"}, DependsOn: []string{"ADR-003", "ADR-004"},
			Alternatives: []Alternative{{"提交后进程内 channel 发布", "崩溃即丢,无法重放"}, {"handler 内直接 produce Kafka", "数据库与 Kafka 双写,任一侧失败都会不一致"}},
			Consequences: Consequences{Positive: []string{"事件与状态同事务,可重放和审计"}, Negative: []string{"relay 带来延迟,outbox 需要清理与积压监控"}}},
		{ID: "ADR-007", Title: "单部署单元,按模块开关灰度", Status: StatusProposed, Date: "2026-10-06T16:00:00+08:00", Drivers: []string{"REQ-01", "REQ-05"}, DependsOn: []string{"ADR-001", "ADR-005"},
			Alternatives: []Alternative{{"每个模块独立部署", "发布与运维成本回到微服务"}, {"只做部署级 canary", "整个进程一起切,无法隔离单个模块的新路径"}},
			Consequences: Consequences{Positive: []string{"爆炸半径小,回滚快"}, Negative: []string{"开关组合增加测试矩阵,旧开关会变成永久分支"}}},
	}}
}

注意 ADR-001 没有 depends_on,但它不是孤立节点,因为 002、003、005、007 都依赖它。孤立判断必须同时看入边和出边,只看“有没有依赖别人”会把根决策误判成孤儿。

规则由表驱动测试固定

测试分两层。第一层断言真实登记表零问题,并抽查三条关键依赖边确实存在;第二层复制登记表、每次只破坏一处,验证对应错误码会出现。这样新增 ADR 时不用重写测试,规则被误删时也会立刻报警。

// file: internal/adrindex/index_test.go
// tier: L1
// verify: go test ./internal/adrindex/ -count=1 -v
package adrindex

import "testing"

func hasCode(issues []Issue, want string) bool {
	for _, issue := range issues {
		if issue.Code == want {
			return true
		}
	}
	return false
}
func TestVolumeOneIndex(t *testing.T) {
	idx := VolumeOne()
	if issues := idx.Validate(); len(issues) != 0 {
		t.Fatalf("VolumeOne() has %d issue(s): %v", len(issues), issues)
	}
	want := map[string]string{"ADR-003": "ADR-002", "ADR-004": "ADR-003", "ADR-006": "ADR-004"}
	for _, e := range idx.Entries {
		if dep, ok := want[e.ID]; ok {
			found := false
			for _, got := range e.DependsOn {
				found = found || got == dep
			}
			if !found {
				t.Fatalf("%s must depend on %s, got %v", e.ID, dep, e.DependsOn)
			}
		}
	}
}
func TestValidateRules(t *testing.T) {
	tests := []struct {
		name   string
		mutate func(*Index)
		want   string
	}{
		{"invalid id", func(in *Index) { in.Entries[0].ID = "ADR-1" }, CodeInvalidID},
		{"duplicate id", func(in *Index) { in.Entries[1].ID = in.Entries[0].ID }, CodeDuplicateID},
		{"invalid status", func(in *Index) { in.Entries[0].Status = "done" }, CodeInvalidStatus},
		{"invalid date", func(in *Index) { in.Entries[0].Date = "2026-10-05" }, CodeInvalidDate},
		{"no driver", func(in *Index) { in.Entries[0].Drivers = nil }, CodeNoDriver},
		{"one alternative", func(in *Index) { in.Entries[0].Alternatives = in.Entries[0].Alternatives[:1] }, CodeNoAlternative},
		{"alternative without reason", func(in *Index) { in.Entries[0].Alternatives[0].Reason = "" }, CodeAlternativeNoReason},
		{"no negative consequence", func(in *Index) { in.Entries[0].Consequences.Negative = nil }, CodeNoConsequence},
		{"dangling dependency", func(in *Index) { in.Entries[1].DependsOn = append(in.Entries[1].DependsOn, "ADR-999") }, CodeDanglingDependency},
		{"dependency cycle", func(in *Index) { in.Entries[0].DependsOn = []string{"ADR-006"} }, CodeDependencyCycle},
		{"isolated adr", func(in *Index) {
			in.Entries = append(in.Entries, Entry{ID: "ADR-099", Title: "占位", Status: StatusAccepted,
				Date: "2026-10-09T00:00:00+08:00", Drivers: []string{"REQ-99"},
				Alternatives: []Alternative{{"A", "r"}, {"B", "r"}},
				Consequences: Consequences{Positive: []string{"p"}, Negative: []string{"n"}}})
		}, CodeIsolatedADR},
		{"superseded without target", func(in *Index) { in.Entries[0].Status = StatusSuperseded }, CodeSupersededMismatch},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			idx := VolumeOne()
			tt.mutate(&idx)
			if !hasCode(idx.Validate(), tt.want) {
				t.Fatalf("issues = %v, want %s", idx.Validate(), tt.want)
			}
		})
	}
}

表驱动测试覆盖了十二条规则,但没有覆盖“决策是否正确”。这是有意的边界:校验器能证明索引自洽,不能证明 PostgreSQL 一定够用,也不能证明 outbox 一定不丢事件。后者要靠 2.3 的评审条件和后续实验。

真实运行结果

以下命令在本机 macOS(darwin/arm64,Go 1.26.0)真实执行,输出未改写:

$ go test ./internal/adrindex/ -count=1
ok  	archlab/internal/adrindex	0.478s
$ go test ./internal/adrindex/ -run TestValidateRules -v | grep -c '^    --- PASS'
12

grep -c 的 12 对应十二条规则子用例;完整 -v 输出还会列出每条的 PASS 行。测试通过只能说明登记表没有结构性问题,不能替代评审。

索引读出的三件事

第一,最长依赖链决定了改动顺序:001 → 002 → 003 → 004 → 006 意味着 outbox 的重新评估必须排在存储和异步边界之后,跳过前两步直接换消息中间件会把旧约束带进新方案。第二,高扇入节点是承重墙:ADR-003 同时被 004 和 006 依赖,它的触发条件应该被监控,而不是等到“业务发展了”才想起来复审。第三,并行支线适合先试点:005 → 007 不与主链耦合,灰度开关可以先小流量验证,但先上开关再补端口会让开关控制到错误的调用路径。

与 2.2 和站内 ADR 文章的分工

2.2 已经写出 ADR-003 到 ADR-007 的完整论证,本节补上 ADR-001 和 ADR-002,把它们放进同一张依赖图,并交付索引校验器。站内的 架构决策记录(ADR) 讲的是 ADR 的起源、模板和团队落地;本节不重复模板,只回答“卷一这七条决策如何互相约束、哪些结构错误能让测试直接失败”。需要回看单条论证时,先读 2.2 订单系统的五个 ADR 。

常见错误与边界

第一,把索引写成目录。只有标题和链接,没有 drivers、depends_on 和 consequences,评审时仍然只能靠记忆判断影响范围。

第二,依赖方向写反。depends_on 表示“我依赖谁”,不是“谁依赖我”;方向一反,拓扑顺序和影响分析都会失真。

第三,备选方案写成稻草人。“什么都不做”和“全部重写”不算认真比较,至少要有两个在当时约束下可信的候选,并写清否决理由。

第四,后果只写正面。没有代价的决策通常意味着代价还没被发现,而不是不存在;校验器强制正负都要有,就是在逼作者补上这一半。

第五,日期填成文章发布日期,或以为校验通过就等于决策正确。决策日期影响“当时掌握了什么证据”的判断;索引只能证明结构自洽,约束是否变化、方案是否仍成立,要靠评审、实验和触发条件监控。

小结

卷一的需求先被压缩成六条驱动因素,再收敛为七条带依赖关系的 ADR:模块化单体是根,同步库存预留和单写库构成主链,履约异步与 outbox 依次挂在后面,端口边界和灰度开关是并行支线。internal/adrindex 把这些关系编码成可校验结构,孤立 ADR、缺失备选、无后果、非法日期和依赖环都会让测试失败。下一节用一次针对候选方案的评审,检验这张索引在真实争论里是否够用。

阅读导航:上一节:12.3 质量属性场景与架构风险清单 · 下一节:13.2 评审一次真实架构方案 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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