《后端架构设计·架构基础与边界》13.2 评审一次真实架构方案

评审一个与 2.3 不同的候选方案:把订单与库存合并为 order-core 单服务。先固定六条证据、四条反对意见和三个重审条件,再用 Go 评分器把证据缺口、未关闭的阻断意见与决策一致性折算成分数;演练明确基于 arch-lab,不是真实公司会议。

评审对象:一个与 2.3 不同的候选方案

本节是一次基于 arch-lab 的评审演练,不是任何真实公司的会议记录;被评审的也不是 2.3 里的现状方案,而是另一个候选:把订单与库存合并为 order-core 单服务。2.3 回答“现在这套模块化单体有什么风险”,本节回答“如果有人提出换一种切法,我们凭什么接受或拒绝”。提案写得足够具体,才有被否证的可能:

提案 P-13-2
目标:把 internal/order 与 internal/inventory 合并为 order-core 单服务
动作:取消两个包之间的端口,改为直接函数调用
理由:减少一次跨服务调用、简化发布、降低两人团队的维护成本

先固定证据,再开始争论

所有证据都来自卷一的 ADR、C4 图和代码结构,没有证据的地方标成意见,不补数字。评审最容易失控的环节是“先有结论,再找理由”。因此第一步不是表态,而是把每条主张连同证据类型、来源和支持方向一起登记。类型只有四种:代码/部署/ADR 属于 artifact,压测属于 experiment,监控属于 measurement,口头判断属于 opinion;前三种可以复核,opinion 只能当线索。

ID证据类型来源方向
E-01order 与 inventory 已在同一个 orderd 进程,模块间是函数调用artifactADR-001、ADR-005、C4 容器图支持现状
E-02库存预留与订单状态在同一 PostgreSQL 本地事务提交artifactADR-003、内存适配器支持现状
E-03库存预留有独立生命周期和不变量,归 inventory 上下文artifact3.3 聚合边界支持保留边界
E-04合并后创建订单 P99 可下降 10msopinion提案人口头估计,无基线支持提案
E-05部署拓扑只有一个二进制,不存在可消除的进程间调用artifactADR-007 单部署单元反驳提案
E-06ADR-001 把按模块拆分列为未来触发条件artifactADR-001 重评条件反驳提案

三条反对意见与一条已解决项

反对意见必须带严重度和状态;已解决项要写清处理方式,否则“已解决”只是口头承诺。

ID严重度意见状态
O-01blocker“减少一次跨服务调用”的前提与当前部署事实矛盾,收益不存在open
O-02major取消端口会把库存不变量并入订单聚合,破坏 3.3 的边界open
O-03major没有拆分回滚与迁移演练,未来拆分成本不可估open
O-04minor值班手册需要更新resolved,并入 C-03

O-01 是阻断项,不是“再讨论一下”。它否证的是提案的核心收益,不是实现细节;核心前提不成立时,继续比较实现方案没有意义。O-02 和 O-03 是重要项,即使前提被修正,也必须用条件约束。

结论:否决当前提案,保留更窄的替代

结论是拒绝当前提案。理由不是“合并一定错”,而是它的第一收益在当前拓扑下不成立,同时删除的边界有真实成本。更窄的替代是:保留 internal/order 与 internal/inventory 两个包和库存预留端口,只合并只读查询端口;如果目标是减少运维对象,当前已经是一个部署单元,合并服务不会减少任何进程。如果提案人坚持推进,重审条件是 C-01 到 C-03:同口径压测、包边界与不变量归属图、回滚与拆分演练;三条都完成且 O-01 被撤回或修正,才进入下一次评审。条件不是“通过后的待办”,而是“重新开会的门票”。

把评审记录变成可运行评分器

下面的包把上述记录编码成结构,并给出两个能力:Validate 检查记录自身是否合法,Assess 把证据缺口和未关闭意见折算成 0–100 分。评分不是投票,它只负责把“哪里没证据”变成可比较的数字。

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

import (
	"strings"
	"time"
)

type (
	EvidenceKind string
	Evidence     struct {
		ID, Claim string
		Kind      EvidenceKind
		Source    string
		Supports  bool
		Benefit   bool
	}
	Severity  string
	Objection struct {
		ID, Claim, Status, Resolution string
		Severity                      Severity
	}
	Condition struct{ ID, Metric, Owner, Due, Verify string }
	Decision  string
	Review    struct {
		ID, Proposal, Date, Rationale string
		Evidence                      []Evidence
		Objections                    []Objection
		Conditions                    []Condition
		Decision                      Decision
	}
	Issue struct{ Code, Ref, Detail string }
)

const (
	KindArtifact    EvidenceKind = "artifact"
	KindExperiment  EvidenceKind = "experiment"
	KindMeasurement EvidenceKind = "measurement"
	KindOpinion     EvidenceKind = "opinion"

	SeverityBlocker Severity = "blocker"
	SeverityMajor   Severity = "major"
	SeverityMinor   Severity = "minor"

	DecisionApprove     Decision = "approve"
	DecisionConditional Decision = "approve_with_conditions"
	DecisionReject      Decision = "reject"
	DecisionDefer       Decision = "defer"

	CodeBadDate                = "BAD_DATE"
	CodeBadDecision            = "BAD_DECISION"
	CodeBadEvidence            = "BAD_EVIDENCE"
	CodeEvidenceNoSource       = "EVIDENCE_NO_SOURCE"
	CodeBadObjection           = "BAD_OBJECTION"
	CodeResolvedWithoutReason  = "RESOLVED_WITHOUT_RESOLUTION"
	CodeConditionIncomplete    = "CONDITION_INCOMPLETE"
	CodeBadConditionDue        = "BAD_CONDITION_DUE"
	CodeApproveWithBlocker     = "APPROVE_WITH_BLOCKER"
	CodeConditionalWithoutCond = "CONDITIONAL_WITHOUT_CONDITION"
	CodeRejectWithoutRationale = "REJECT_WITHOUT_RATIONALE"
)

func (k EvidenceKind) Valid() bool {
	return k == KindArtifact || k == KindExperiment || k == KindMeasurement || k == KindOpinion
}

func (s Severity) Valid() bool {
	return s == SeverityBlocker || s == SeverityMajor || s == SeverityMinor
}

func (o Objection) Open() bool { return o.Status == "open" }

func (d Decision) Valid() bool {
	return d == DecisionApprove || d == DecisionConditional || d == DecisionReject || d == DecisionDefer
}

func (r Review) Validate() []Issue {
	var out []Issue
	add := func(code, ref, detail string) { out = append(out, Issue{code, ref, detail}) }
	if _, err := time.Parse(time.RFC3339, r.Date); err != nil {
		add(CodeBadDate, r.ID, r.Date)
	}
	if !r.Decision.Valid() {
		add(CodeBadDecision, r.ID, string(r.Decision))
	}
	for _, e := range r.Evidence {
		if !e.Kind.Valid() {
			add(CodeBadEvidence, e.ID, string(e.Kind))
		}
		if strings.TrimSpace(e.Source) == "" {
			add(CodeEvidenceNoSource, e.ID, "no source")
		}
	}
	blocker := false
	for _, o := range r.Objections {
		if !o.Severity.Valid() || (o.Status != "open" && o.Status != "resolved") {
			add(CodeBadObjection, o.ID, string(o.Severity))
		}
		if o.Status == "resolved" && strings.TrimSpace(o.Resolution) == "" {
			add(CodeResolvedWithoutReason, o.ID, "no resolution")
		}
		blocker = blocker || (o.Open() && o.Severity == SeverityBlocker)
	}
	for _, c := range r.Conditions {
		if c.ID == "" || c.Metric == "" || c.Owner == "" || c.Verify == "" {
			add(CodeConditionIncomplete, c.ID, "missing field")
		}
		if _, err := time.Parse("2006-01-02", c.Due); err != nil {
			add(CodeBadConditionDue, c.ID, c.Due)
		}
	}
	if blocker && (r.Decision == DecisionApprove || r.Decision == DecisionConditional) {
		add(CodeApproveWithBlocker, r.ID, "blocker must close first")
	}
	if r.Decision == DecisionConditional && len(r.Conditions) == 0 {
		add(CodeConditionalWithoutCond, r.ID, "no conditions")
	}
	if r.Decision == DecisionReject && strings.TrimSpace(r.Rationale) == "" {
		add(CodeRejectWithoutRationale, r.ID, "reason required")
	}
	return out
}

type Deduction struct {
	Reason string
	Points int
}

type Assessment struct {
	Score      int
	Verdict    Decision
	Deductions []Deduction
	Issues     []Issue
}

func (r Review) Assess() Assessment {
	a := Assessment{Score: 100, Issues: r.Validate()}
	deduct := func(reason string, points int) {
		a.Deductions = append(a.Deductions, Deduction{reason, points})
		a.Score -= points
	}
	opinionBenefits, hasArtifact, hasMeasurement := 0, false, false
	for _, e := range r.Evidence {
		hasArtifact = hasArtifact || e.Kind == KindArtifact
		hasMeasurement = hasMeasurement || e.Kind == KindExperiment || e.Kind == KindMeasurement
		if e.Supports && e.Benefit && e.Kind == KindOpinion {
			opinionBenefits++
		}
	}
	if opinionBenefits > 0 {
		deduct("收益主张只有意见证据", 10*min(opinionBenefits, 3))
	}
	if !hasArtifact {
		deduct("没有代码、部署或 ADR 证据", 15)
	}
	if !hasMeasurement {
		deduct("没有实验或测量证据", 15)
	}
	openBlocker, openMajor := 0, 0
	for _, o := range r.Objections {
		if o.Open() && o.Severity == SeverityBlocker {
			openBlocker++
		}
		if o.Open() && o.Severity == SeverityMajor {
			openMajor++
		}
	}
	if openBlocker > 0 {
		deduct("未关闭的阻断意见", 25*openBlocker)
	}
	if openMajor > 0 {
		deduct("未关闭的重要意见", 8*openMajor)
	}
	switch {
	case openBlocker > 0:
		a.Verdict = DecisionReject
	case openMajor > 0 && a.Score >= 60:
		a.Verdict = DecisionConditional
	case a.Score >= 75:
		a.Verdict = DecisionApprove
	default:
		a.Verdict = DecisionDefer
	}
	if a.Score < 0 {
		a.Score = 0
	}
	return a
}

真实记录放在 case.go。六条证据、四条意见、三个条件都只是数据,评审结论也是数据的一部分;这样下次改一条意见,分数和结论会一起重算,而不是散落在会议纪要里。

// file: internal/review/case.go
// tier: L1
// verify: go test ./internal/review/ -run TestCaseAssessment -v
package review

func Case() Review {
	return Review{
		ID:       "REV-2026-13-2",
		Proposal: "把 internal/order 与 internal/inventory 合并为 order-core 单服务,取消两者之间的端口,改为直接函数调用;理由是减少一次跨服务调用、简化发布、降低两人团队的维护成本。",
		Date:     "2026-10-12T09:00:00+08:00", Decision: DecisionReject,
		Rationale: "提案的第一个收益前提与当前部署事实矛盾:两个模块本来就在同一进程,没有可消除的网络 RPC;真正被删除的是领域边界。",
		Evidence: []Evidence{
			{ID: "E-01", Claim: "order 与 inventory 已运行在同一个 orderd 进程,模块间是函数调用", Kind: KindArtifact, Source: "ADR-001、ADR-005 与 C4 容器图", Supports: true},
			{ID: "E-02", Claim: "库存预留与订单状态变更在同一个 PostgreSQL 本地事务提交", Kind: KindArtifact, Source: "ADR-003、internal/adapters/memory", Supports: true},
			{ID: "E-03", Claim: "库存预留有独立生命周期和不变量,归 inventory 上下文", Kind: KindArtifact, Source: "3.3 聚合与一致性边界", Supports: true},
			{ID: "E-04", Claim: "合并后创建订单 P99 可下降 10ms", Kind: KindOpinion, Source: "提案人口头估计,没有基线", Supports: true, Benefit: true},
			{ID: "E-05", Claim: "部署拓扑只有一个二进制,不存在可消除的进程间调用", Kind: KindArtifact, Source: "卷一的单部署单元 ADR-007", Supports: false},
			{ID: "E-06", Claim: "ADR-001 把按模块拆分列为未来触发条件", Kind: KindArtifact, Source: "ADR-001 重评触发条件", Supports: false},
		},
		Objections: []Objection{
			{ID: "O-01", Severity: SeverityBlocker, Status: "open", Claim: "减少一次跨服务调用的前提与当前部署事实矛盾,收益不存在"},
			{ID: "O-02", Severity: SeverityMajor, Status: "open", Claim: "取消端口会把库存不变量并入订单聚合,破坏 3.3 的边界"},
			{ID: "O-03", Severity: SeverityMajor, Status: "open", Claim: "没有拆分回滚与迁移演练,未来拆分成本不可估"},
			{ID: "O-04", Severity: SeverityMinor, Status: "resolved", Claim: "值班手册需要更新", Resolution: "已列入 C-03,由平台组在灰度前更新"},
		},
		Conditions: []Condition{
			{ID: "C-01", Metric: "合并前后同口径创建订单 P99 与吞吐", Owner: "performance", Due: "2026-11-15", Verify: "ab -n 2000 -c 50 POST /api/v1/orders"},
			{ID: "C-02", Metric: "order-core 包边界与库存不变量归属图", Owner: "domain", Due: "2026-11-20", Verify: "docs/adr/ADR-008.md 的 Boundary 一节"},
			{ID: "C-03", Metric: "回滚开关与拆分演练记录", Owner: "platform", Due: "2026-11-30", Verify: "runbook 的 Rollback 一节"},
		},
	}
}

测试固定两件事:当前记录必须得到 34 分和 reject;把阻断意见解决、补上测量证据后,分数应升到 74 并给出有条件通过。表驱动用例再逐条破坏记录,确认校验器能报出对应错误。

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

import "testing"

func hasCode(issues []Issue, want string) bool {
	for _, issue := range issues {
		if issue.Code == want {
			return true
		}
	}
	return false
}
func TestCaseAssessment(t *testing.T) {
	a := Case().Assess()
	if len(a.Issues) != 0 || a.Score != 34 || a.Verdict != DecisionReject {
		t.Fatalf("issues/score/verdict = %v/%d/%s", a.Issues, a.Score, a.Verdict)
	}
	t.Logf("score=%d verdict=%s", a.Score, a.Verdict)
	for _, d := range a.Deductions {
		t.Logf("deduction: %s (-%d)", d.Reason, d.Points)
	}
}
func TestResolvedCaseCanBeConditionallyApproved(t *testing.T) {
	r := Case()
	r.Objections[0].Status = "resolved"
	r.Objections[0].Resolution = "提案人撤回“减少 RPC”的收益主张"
	r.Evidence = append(r.Evidence, Evidence{ID: "E-07", Claim: "合并前后 P99 差异在 2ms 以内", Kind: KindMeasurement, Source: "C-01 的压测报告", Supports: true, Benefit: true})
	r.Decision = DecisionConditional
	a := r.Assess()
	if a.Verdict != DecisionConditional || a.Score < 60 {
		t.Fatalf("score/verdict = %d/%s, want >=60/conditional", a.Score, a.Verdict)
	}
	t.Logf("resolved score=%d verdict=%s", a.Score, a.Verdict)
}
func TestValidateRules(t *testing.T) {
	tests := []struct {
		name   string
		mutate func(*Review)
		want   string
	}{
		{"bad date", func(r *Review) { r.Date = "2026-10-12" }, CodeBadDate},
		{"bad decision", func(r *Review) { r.Decision = "ship" }, CodeBadDecision},
		{"bad evidence kind", func(r *Review) { r.Evidence[0].Kind = "rumor" }, CodeBadEvidence},
		{"evidence without source", func(r *Review) { r.Evidence[0].Source = "" }, CodeEvidenceNoSource},
		{"bad objection status", func(r *Review) { r.Objections[0].Status = "maybe" }, CodeBadObjection},
		{"resolved without reason", func(r *Review) { r.Objections[3].Resolution = "" }, CodeResolvedWithoutReason},
		{"condition incomplete", func(r *Review) { r.Conditions[0].Owner = "" }, CodeConditionIncomplete},
		{"bad condition due", func(r *Review) { r.Conditions[0].Due = "2026/11/15" }, CodeBadConditionDue},
		{"approve with blocker", func(r *Review) { r.Decision = DecisionApprove }, CodeApproveWithBlocker},
		{"conditional without conditions", func(r *Review) {
			r.Objections[0].Status, r.Objections[0].Resolution = "resolved", "fixed"
			r.Decision, r.Conditions = DecisionConditional, nil
		}, CodeConditionalWithoutCond},
		{"reject without rationale", func(r *Review) { r.Rationale = "" }, CodeRejectWithoutRationale},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			r := Case()
			tt.mutate(&r)
			if !hasCode(r.Validate(), tt.want) {
				t.Fatalf("issues = %v, want %s", r.Validate(), tt.want)
			}
		})
	}
}

真实运行结果

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

$ go test ./internal/review/ -run 'TestCaseAssessment|TestResolved' -count=1 -v | grep -E 'score=|deduction:'
    review_test.go:21: score=34 verdict=reject
    review_test.go:23: deduction: 收益主张只有意见证据 (-10)
    review_test.go:23: deduction: 没有实验或测量证据 (-15)
    review_test.go:23: deduction: 未关闭的阻断意见 (-25)
    review_test.go:23: deduction: 未关闭的重要意见 (-16)
    review_test.go:36: resolved score=74 verdict=approve_with_conditions

34 分来自 100 − 10(意见收益)− 15(无测量)− 25(阻断项)− 16(两个重要项);另有 11 条校验子用例全部通过。补上测量并解决阻断项后剩下 74 分,仍有两个重要项,所以结论只能是“有条件通过”,不能是无条件批准。

分数只暴露结构缺口,不能替代对边界的判断:artifact 证明现状,experiment 和 measurement 才能支持性能主张;E-04 即使写十次仍是 opinion。分数也是输入而非裁决,当前 34 分与 reject 的结论一致,是因为存在阻断项。

常见错误与边界

第一,把评审开成表态会,或把意见当测量。没有证据登记,反对意见只能停留在“我觉得”;“预计下降 10ms”也不能进入收益计算,除非有基线、有同口径命令、有可复现输出。第二,阻断项和重要项混为一谈:阻断项关闭前不应比较实现方案,把它们都标成“待办”会让结论失去约束力。第三,条件写成“后续优化”,或用分数替代判断。没有 owner、指标、截止日期和验证方式的条件无法验收;评分器也只能检查记录结构和证据类型,不能判断业务约束是否真的成立,结论仍要由评审组给出并写明理由。

小结

这次演练评审的是“订单与库存合并”这一候选方案,而不是 2.3 的现状方案。证据表把六条主张分成可复核的 artifact 与不可复核的 opinion,反对意见表把阻断项和重要项分开,结论因此是拒绝当前提案、保留更窄的查询端口合并。internal/review 把记录、校验和评分变成可运行代码:当前记录 34 分、verdict 为 reject;补上测量并解决阻断项后为 74 分、verdict 为有条件通过。下一节交接卷一基线,进入卷二要问的吞吐、尾延迟、容量与故障问题。

阅读导航:上一节:13.1 从需求到架构决策记录 · 下一节:13.3 进入卷二:性能与可用性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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