《后端架构设计·架构基础与边界》9.2 架构决策权与团队契约

以 arch-lab 的决策权矩阵为例,把谁决策、谁执行、谁验收写成 D/E/V/C 四类角色,并给出平台团队与业务团队的可验收契约字段;配套 decision 校验器用纯函数检查决策越权、缺咨询、自审、缺验收与破坏性变更通知,附真实测试输出。

三个 +1 不算评审

2026 年 8 月,order API 要给 POST /api/v1/orders 增加一个必填字段。三个月后回看,没人说得清是谁拍的板:聊天记录里只有一句“业务同意了”,评审记录里只有三个 +1。字段已经上线,两个外部调用方被迫连夜改造。决策权要回答三个问题:谁决策(D)、谁执行(E)、谁验收(V),外加谁必须被咨询(C)。RACI 也能表达这件事,但它把“执行”和“负责”混在 R 里,又允许一列出现多个 A,落到跨团队时经常变成“人人有责”。本节用 D/E/V/C:每个决策类恰好一个 D 和一个 V,且验收必须由 D 以外的人完成。9.1 已经画清团队、模块和沟通通道,本节回答另一个问题:通道有了,事情谁说了算;ADR 的写法在 2.2 已经给过模板,这里只关心决策权怎么分配、契约怎么验收、评审记录怎么变成可检查的数据。

决策权矩阵

决策权跟随决策范围,不跟随职级。业务规则的 D 是 DOMAIN,平台的 D 是 PLATFORM,跨切面的 D 是 ARCH;任何范围的 V 都必须独立于 D。矩阵只写角色,不写人名;角色稳定,人会轮换。

决策类范围D 决策E 执行V 验收C 咨询
DC-ORDER-LOCALBUSINESSDOMAINDOMAINDOMAIN无
DC-DATA-OWNERSHIPCROSS_CUTTINGARCHPLATFORM、DOMAINSECURITYDOMAIN、PLATFORM
DC-PLATFORM-INTERFACEPLATFORMPLATFORMPLATFORMSREDOMAIN
DC-SECURITY-EXCEPTIONCROSS_CUTTINGARCHSECURITY、PLATFORMSECURITYDOMAIN、PLATFORM

这张表有两条硬约束。第一,平台不能替业务决定业务规则:DC-ORDER-LOCAL 的 D 只能是 DOMAIN,平台可以执行、可以被咨询,但不能签字。第二,业务也不能单方面改平台接口:DC-PLATFORM-INTERFACE 的 D 是 PLATFORM,但 C 必须有 DOMAIN;没有业务方签字的平台契约,验收时会被真实调用方打回。跨切面决策最容易变成“架构组说了算”,所以矩阵要求 D 是 ARCH、C 必须包含 DOMAIN 和 PLATFORM:咨询不是投票,D 仍然只有一个,但记录里必须留下咨询证据。

平台与业务团队的契约

条款写法谁负责
接口与版本v2;破坏性变更升主版本平台
SLI可用性 99.9%,P99 80ms,30 天窗口平台
变更通知破坏性变更提前 30 天平台
消费方验收业务团队跑契约测试并签字业务
退出与升级旧版本下线日期、故障升级联系人双方

契约校验器检查团队存在、SLI 范围、破坏性变更的通知天数与主版本号。它不测真实延迟,那是监控和契约测试的事;它只保证契约在纸面上可执行。比如版本还是 v1 却标记破坏性变更,或者只给七天通知,评审阶段就应该被拦下。

把规则写成校验器

decision 包把决策权矩阵、平台契约和评审记录放在同一个账本里。Validate 是纯函数:不读当前时间、不读文件,输入相同,输出相同。这样测试可以稳定复现,CI 也不会因为跨天而变红。

// file: internal/decision/decision.go
// tier: L1
// verify: go test ./internal/decision/
package decision

import (
	"fmt"
	"slices"
	"strconv"
	"strings"
)

type Role string

const (
	Arch     Role = "ARCH"
	Platform Role = "PLATFORM"
	Domain   Role = "DOMAIN"
	Security Role = "SECURITY"
	SRE      Role = "SRE"
)

type Scope string

const (
	Business      Scope = "BUSINESS"
	PlatformScope Scope = "PLATFORM"
	CrossCutting  Scope = "CROSS_CUTTING"
)

type Person struct {
	ID    string
	Team  string
	Roles []Role
}

// Class 是一类决策,不是某一次决策。D 决策、E 执行、V 验收、C 必须被咨询。
type Class struct {
	ID      string
	Scope   Scope
	Decide  Role
	Execute []Role
	Verify  Role
	Consult []Role
}

// Contract 是平台与业务团队之间的可验收承诺,不是服务目录里的介绍。
type Contract struct {
	ID              string
	Provider        string
	Consumer        string
	Version         string
	Breaking        bool
	NoticeDays      int
	AvailabilityPct float64
	P99Millis       int
}
type Verdict string

const (
	Approved              Verdict = "APPROVED"
	ApprovedWithException Verdict = "APPROVED_WITH_EXCEPTION"
)

type Approval struct {
	Reviewer string
	Verdict  Verdict
	Evidence string
}
type Review struct {
	ID        string
	ClassID   string
	Author    string
	Approvals []Approval
}
type Ledger struct {
	Classes   []Class
	People    []Person
	Contracts []Contract
	Reviews   []Review
}
type Finding struct {
	Code    string
	Subject string
	Detail  string
}

// Validate 检查决策权矩阵、平台契约和评审记录。它是纯函数:
// 同样的账本永远得到同样的违规,不依赖运行时间和外部系统。
func (l Ledger) Validate() []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})
	}

	people := make(map[string]Person, len(l.People))
	teams := make(map[string]bool, len(l.People))
	for _, p := range l.People {
		people[p.ID] = p
		teams[p.Team] = true
	}

	classes := make(map[string]Class, len(l.Classes))
	for _, c := range l.Classes {
		classes[c.ID] = c
		switch c.Scope {
		case Business:
			if c.Decide != Domain {
				add("DECISION_SCOPE_CONFLICT", c.ID, "business decision must be DOMAIN")
			}
		case PlatformScope:
			if c.Decide != Platform {
				add("DECISION_SCOPE_CONFLICT", c.ID, "platform decision must be PLATFORM")
			}
			if !includes(c.Consult, Domain) {
				add("MISSING_CONSULTATION", c.ID, "platform contract needs a DOMAIN consumer")
			}
		case CrossCutting:
			if c.Decide != Arch {
				add("DECISION_SCOPE_CONFLICT", c.ID, "cross-cutting decision must be ARCH")
			}
			if !includes(c.Consult, Domain) || !includes(c.Consult, Platform) {
				add("MISSING_CONSULTATION", c.ID, "cross-cutting decision needs DOMAIN and PLATFORM")
			}
		}
		if len(c.Execute) == 0 || c.Verify == "" {
			add("INCOMPLETE_CLASS", c.ID, "executor and verifier are required")
		}
	}

	for _, ct := range l.Contracts {
		if !teams[ct.Provider] || !teams[ct.Consumer] {
			add("UNKNOWN_CONTRACT_TEAM", ct.ID, ct.Provider+" -> "+ct.Consumer)
		}
		if ct.Provider == ct.Consumer {
			add("SELF_CONTRACT", ct.ID, ct.Provider)
		}
		if ct.AvailabilityPct <= 0 || ct.AvailabilityPct > 100 || ct.P99Millis <= 0 {
			add("SLI_OUT_OF_RANGE", ct.ID, fmt.Sprintf("availability=%.2f p99=%d", ct.AvailabilityPct, ct.P99Millis))
		}
		if ct.Breaking && (ct.NoticeDays < 30 || major(ct.Version) < 2) {
			add("BREAKING_CHANGE_UNSAFE", ct.ID, fmt.Sprintf("notice=%dd version=%s", ct.NoticeDays, ct.Version))
		}
	}

	for _, r := range l.Reviews {
		class, ok := classes[r.ClassID]
		if !ok {
			add("UNKNOWN_CLASS", r.ID, r.ClassID)
			continue
		}
		decider, verifier, approved := "", "", false
		for _, a := range r.Approvals {
			if a.Reviewer == r.Author {
				add("SELF_APPROVAL", r.ID, a.Reviewer)
				continue
			}
			if _, ok := people[a.Reviewer]; !ok {
				add("UNKNOWN_PERSON", r.ID, a.Reviewer)
				continue
			}
			if a.Evidence == "" {
				add("MISSING_EVIDENCE", r.ID, a.Reviewer)
			}
			approved = true
			if decider == "" && holds(people, a.Reviewer, class.Decide) {
				decider = a.Reviewer
			}
			if a.Reviewer != decider && verifier == "" && holds(people, a.Reviewer, class.Verify) {
				verifier = a.Reviewer
			}
		}
		if !approved || decider == "" {
			add("NO_EFFECTIVE_DECISION", r.ID, "no approver holds "+string(class.Decide))
		}
		if verifier == "" {
			add("MISSING_VERIFIER_APPROVAL", r.ID, string(class.Verify)+" never signed")
		}
	}
	return out
}

func includes(values []Role, want Role) bool {
	return slices.Contains(values, want)
}

func holds(people map[string]Person, id string, role Role) bool {
	p, ok := people[id]
	return ok && slices.Contains(p.Roles, role)
}

func major(version string) int {
	n, err := strconv.Atoi(strings.TrimPrefix(version, "v"))
	if err != nil {
		return 0
	}
	return n
}

代码里 V 的选择有一个细节:验收人必须是持有 V 角色、且不是 D 的人。如果只有 D 一个人签字,报 MISSING_VERIFIER_APPROVAL 而不是“通过”。这比在矩阵里写一句“V 不得与 D 相同”更硬,因为矩阵管角色,记录管人。

测试:五个变异

基线是四类决策、七个人、两份契约、三条评审记录,零违规。五个变异分别模拟决策越权、平台契约缺业务咨询、作者自审、验收人缺席、破坏性变更通知不足。

// file: internal/decision/decision_test.go
// tier: L1
// verify: go test ./internal/decision/
package decision

import "testing"

func baseline() Ledger {
	return Ledger{
		Classes: []Class{
			{ID: "DC-ORDER-LOCAL", Scope: Business, Decide: Domain, Execute: []Role{Domain}, Verify: Domain},
			{ID: "DC-DATA-OWNERSHIP", Scope: CrossCutting, Decide: Arch, Execute: []Role{Platform, Domain}, Verify: Security, Consult: []Role{Domain, Platform}},
			{ID: "DC-PLATFORM-INTERFACE", Scope: PlatformScope, Decide: Platform, Execute: []Role{Platform}, Verify: SRE, Consult: []Role{Domain}},
			{ID: "DC-SECURITY-EXCEPTION", Scope: CrossCutting, Decide: Arch, Execute: []Role{Security, Platform}, Verify: Security, Consult: []Role{Domain, Platform}},
		},
		People: []Person{
			{ID: "alice", Team: "team-order", Roles: []Role{Domain}},
			{ID: "bob", Team: "team-order", Roles: []Role{Domain}},
			{ID: "carol", Team: "team-catalog", Roles: []Role{Domain}},
			{ID: "grace", Team: "team-platform", Roles: []Role{Platform}},
			{ID: "heidi", Team: "team-arch", Roles: []Role{Arch}},
			{ID: "judy", Team: "team-sre", Roles: []Role{SRE}},
			{ID: "karl", Team: "team-security", Roles: []Role{Security}},
		},
		Contracts: []Contract{
			{ID: "CT-ORDER-EVENTS", Provider: "team-order", Consumer: "team-catalog", Version: "v2", Breaking: true, NoticeDays: 45, AvailabilityPct: 99.95, P99Millis: 120},
			{ID: "CT-IDEMPOTENCY", Provider: "team-platform", Consumer: "team-order", Version: "v2", AvailabilityPct: 99.9, P99Millis: 80},
		},
		Reviews: []Review{
			{ID: "R-1001", ClassID: "DC-DATA-OWNERSHIP", Author: "alice", Approvals: []Approval{{"heidi", Approved, "ADR-021"}, {"karl", Approved, "security-88"}, {"grace", Approved, "signoff"}}},
			{ID: "R-1002", ClassID: "DC-PLATFORM-INTERFACE", Author: "alice", Approvals: []Approval{{"grace", Approved, "contract-v2"}, {"judy", Approved, "slo-check"}, {"carol", Approved, "consumer-review"}}},
			{ID: "R-1003", ClassID: "DC-ORDER-LOCAL", Author: "bob", Approvals: []Approval{{"alice", Approved, "state-machine-test"}, {"carol", Approved, "second-review"}}},
		},
	}
}

func TestBaselineReport(t *testing.T) {
	l := baseline()
	findings := l.Validate()
	t.Logf("classes=%d people=%d contracts=%d reviews=%d findings=%d",
		len(l.Classes), len(l.People), len(l.Contracts), len(l.Reviews), len(findings))
	if len(findings) != 0 {
		t.Fatalf("baseline should be clean, got %v", findings)
	}
}

func TestValidateCatchesPowerDrift(t *testing.T) {
	cases := []struct {
		name    string
		mutate  func(*Ledger)
		code    string
		subject string
	}{
		{"business decided by platform", func(l *Ledger) {
			l.Classes[0].Decide = Platform
		}, "DECISION_SCOPE_CONFLICT", "DC-ORDER-LOCAL"},
		{"platform without consumer consultation", func(l *Ledger) {
			l.Classes[2].Consult = nil
		}, "MISSING_CONSULTATION", "DC-PLATFORM-INTERFACE"},
		{"author approves own decision", func(l *Ledger) {
			l.Reviews[2].Approvals = append(l.Reviews[2].Approvals, Approval{"bob", Approved, "self"})
		}, "SELF_APPROVAL", "R-1003"},
		{"verifier never signed", func(l *Ledger) {
			l.Reviews[1].Approvals = removeApproval(l.Reviews[1].Approvals, "judy")
		}, "MISSING_VERIFIER_APPROVAL", "R-1002"},
		{"breaking change with short notice", func(l *Ledger) {
			l.Contracts[0].NoticeDays = 7
		}, "BREAKING_CHANGE_UNSAFE", "CT-ORDER-EVENTS"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			l := baseline()
			tc.mutate(&l)
			if !has(l.Validate(), tc.code, tc.subject) {
				t.Fatalf("want %s on %q, got %v", tc.code, tc.subject, l.Validate())
			}
		})
	}
}

func removeApproval(values []Approval, reviewer string) []Approval {
	out := values[:0]
	for _, v := range values {
		if v.Reviewer != reviewer {
			out = append(out, v)
		}
	}
	return out
}

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/decision/ -run TestBaselineReport -v
=== RUN   TestBaselineReport
    decision_test.go:37: classes=4 people=7 contracts=2 reviews=3 findings=0
--- PASS: TestBaselineReport (0.00s)
PASS
ok  	archlab/internal/decision	0.454s

基线零违规;完整测试的五个变异全部被抓住,gofmt -l 无输出,go vet ./... 无告警。这些数字也能当治理指标:决策类增加而评审记录没增加,说明新决策没有留痕;契约增加而 C 列没变,说明平台在单方面扩接口。

常见错误与边界

第一,把 D 给职位最高的人。D 应该给承担后果、能在故障群里做取舍的角色;职位高但不值班的人签字,等于把责任转嫁给执行者。第二,矩阵里写人名。人一换矩阵就失效,应该写角色,人名只出现在评审记录里。

第三,D 和 V 是同一个人。自己决策、自己验收,记录再完整也只是形式;校验器会要求 V 由另一人担任。第四,契约只写“尽力而为”。没有 SLI、版本规则和通知期,契约无法验收,故障时只能争论“当初说没说”。

边界也要说清楚:跨公司接口、开源社区贡献者和法务合规签字不适用这套矩阵;紧急故障可以事后补记录,但补记录时必须保留当时的决策依据,不能把事后解释写成事前审批。

小结

决策权的落地物不是一张组织图,而是三样可检查的东西:一张按范围分配 D/E/V/C 的矩阵、一份带 SLI 和变更规则的平台契约、以及每条决策的评审记录。arch-lab 的 internal/decision 把三者放进同一个账本,用纯函数校验越权、缺咨询、自审、缺验收和破坏性变更通知。基线零违规、五个变异全部被抓住。本节只固定决策权归谁和单条记录是否完整;9.3 把单条记录扩展成带时间维度的最小治理流程,例外、复审和指标都在那里展开。

阅读导航:上一节:9.1 康威定律与团队拓扑 · 下一节:9.3 架构治理的最小流程 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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