评审对象:一个与 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-01 | order 与 inventory 已在同一个 orderd 进程,模块间是函数调用 | artifact | ADR-001、ADR-005、C4 容器图 | 支持现状 |
| E-02 | 库存预留与订单状态在同一 PostgreSQL 本地事务提交 | artifact | ADR-003、内存适配器 | 支持现状 |
| E-03 | 库存预留有独立生命周期和不变量,归 inventory 上下文 | artifact | 3.3 聚合边界 | 支持保留边界 |
| E-04 | 合并后创建订单 P99 可下降 10ms | opinion | 提案人口头估计,无基线 | 支持提案 |
| E-05 | 部署拓扑只有一个二进制,不存在可消除的进程间调用 | artifact | ADR-007 单部署单元 | 反驳提案 |
| E-06 | ADR-001 把按模块拆分列为未来触发条件 | artifact | ADR-001 重评条件 | 反驳提案 |
三条反对意见与一条已解决项
反对意见必须带严重度和状态;已解决项要写清处理方式,否则“已解决”只是口头承诺。
| ID | 严重度 | 意见 | 状态 |
|---|---|---|---|
| O-01 | blocker | “减少一次跨服务调用”的前提与当前部署事实矛盾,收益不存在 | open |
| O-02 | major | 取消端口会把库存不变量并入订单聚合,破坏 3.3 的边界 | open |
| O-03 | major | 没有拆分回滚与迁移演练,未来拆分成本不可估 | open |
| O-04 | minor | 值班手册需要更新 | 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 进入卷二:性能与可用性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。