《后端架构设计·架构基础与边界》9.1 康威定律与团队拓扑

以 arch-lab 的五团队十一模块映射为例,说明康威定律如何同时解释和塑造包边界;给出有向沟通通道模型与可运行的 orgmap 校验器,把无主模块、一人跨域、通道方向与依赖方向冲突变成 go test 能抓到的违规,并附本机真实运行输出。

一次没有改变目录的越界

arch-lab 的支付回调曾经出过一次故障:订单团队为了赶在当天修复,直接改了 internal/adapters/postgres 里的结算 SQL,没有通知平台团队。目录结构一行没变,评审也通过了,但责任边界已经破了。两周后平台团队做连接池改造,order 的这条 SQL 被一起改坏,双方才发现自己对同一段代码有两种假设。

这件事用康威定律解释很直接:系统结构会复制组织的沟通结构。order 团队能改 adapters,说明日常沟通里 adapters 被当成了“公共地带”;平台团队没有参与那次评审,说明这条通道本来就不存在。目录只是结果,沟通结构才是原因。康威定律有两种用法:第一种是诊断,看到包边界和团队边界对不上,先去看谁和谁在同一个频道里讨论问题;第二种是设计,也就是反向康威,先调整团队归属和协作通道,再期待模块边界跟着变。后者更慢,但改变的是长期约束;只在代码里加一层禁止 import 的检查,能撑住一阵,撑不住一年。3.1 已经把限界上下文和团队 owner 的对应关系讲清楚了,本节不重复那张表,只往前推一步:把“团队、模块、通道”写成可校验的数据,让错位在 CI 里暴露。

团队拓扑在 arch-lab 的落点

Team Topologies 把团队分成四类:流对齐团队(stream-aligned)直接交付业务价值;平台团队提供自助式能力;赋能团队短期进入业务团队补能力;复杂子系统团队维护需要稀缺专长的部件。arch-lab 目前有四个流对齐团队和一个平台团队。如果未来引入搜索推荐或实时风控,才需要考虑复杂子系统团队;现在硬套四类团队只会增加空壳。交互模式同样只需要三种:协作(collaboration)、X-as-a-Service、赋能(facilitating)。order 对 catalog、inventory、payment 是 X-as-a-Service,通过命令和查询接口交互;促销大改期间 order 和 catalog 会短期协作;平台团队接入新服务时做赋能,结束后退出。把交互模式写下来,是为了回答一个具体问题:出故障时谁先看、谁能改对方的代码。

团队类型成员拥有模块主动沟通对象
team-orderstream-alignedalice, bobcmd/orderd、internal/ordercatalog、inventory、payments、platform
team-catalogstream-alignedcarolinternal/catalogplatform
team-inventorystream-aligneddaveinternal/inventoryplatform
team-paymentsstream-alignederininternal/paymentplatform
team-platformplatformfrank, graceinternal/ports、internal/adapters/、internal/platform/四个业务团队

模块依赖不只看包名,还要看谁拥有它。下表把 arch-lab 的跨团队依赖压成五对,并写出依赖方向要求的通道方向。注意“通道”是有向的:它表示下游能向上游提需求、报故障、要求排期,不只是上游能发公告。

依赖方 → 提供方代表边需要的通道
order → cataloginternal/order imports internal/catalogorder → catalog
order → inventoryinternal/order imports internal/inventoryorder → inventory
order → paymentsinternal/order imports internal/paymentorder → payments
payments → platforminternal/payment imports internal/portspayments → platform
order → platformcmd/orderd imports internal/adapters/postgresorder → platform

这张表暴露了一个常见误判:平台团队和所有业务团队都有通道,看起来“连接充分”。但通道数量不等于通道质量,平台向业务团队广播版本公告,和业务团队能要求平台修一个阻塞上线的缺陷,是两件事。校验器因此只检查直接边,不做传递闭包:经过平台中转的“可达”不是承诺。

三条可检查的错位

无主模块

模块能编译、能发布,不等于有人对它负责。internal/catalog 的 owner 为空,或者写着一个已经不存在的团队名,都会让变更评审失去默认责任人。无主模块最常见的来源是临时抽出的共享包:抽的时候为了方便,谁都没接手,最后变成所有人都能改、没人敢删的地方。

一人跨域

同一个人同时出现在两个流对齐团队的名单里,问题不在于他是否忙得过来,而在于两个团队的需求冲突时谁裁决。如果 alice 既是 order 又是 catalog 的成员,促销规则和订单状态机打架,她既是原告又是法官。平台或赋能团队的成员出现在业务团队中是正常协作,不应报错;但流对齐团队之间的重叠要显式记录,并给出退出时间。

沟通方向与依赖方向

依赖方向是 consumer → provider:order 调 catalog,order 是下游。通道也必须能从 order 指向 catalog。只有 catalog → order 的通道时,图上“有箭头”,但 order 无法要求 catalog 修接口,只能等对方排期。这种反向错位比完全没通道更难发现,因为巡检时只看“两个团队有没有连接”,答案是有。

把映射表变成校验器

下面的 orgmap 包把团队、模块和通道写成结构,Validate 一次返回全部违规,而不是遇到第一条就退出。去重键用 code + subject,避免同一个团队对的多条依赖刷屏。TalksTo 明确是有向的,这是整个模型里最容易被简化掉、也最不该简化的一处。

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

import (
	"fmt"
	"strings"
)

// TeamKind 对应 Team Topologies 的四类团队,决定跨团队协作的豁免范围。
type TeamKind string

const (
	StreamAligned TeamKind = "STREAM_ALIGNED"
	Platform      TeamKind = "PLATFORM"
	Enabling      TeamKind = "ENABLING"
	Complicated   TeamKind = "COMPLICATED_SUBSYSTEM"
)

// Team.TalksTo 是有向的:它表示本团队能主动向目标团队发起请求或升级故障。
// 平台群发公告不等于业务团队能向平台提单,所以方向必须记录。
type Team struct {
	ID      string
	Kind    TeamKind
	Members []string
	TalksTo []string
}

type Module struct {
	Path    string
	Owner   string
	Imports []string
}

type Map struct {
	Teams   []Team
	Modules []Module
}

type Finding struct {
	Code    string
	Subject string
	Detail  string
}

func (f Finding) String() string {
	return fmt.Sprintf("%s %s: %s", f.Code, f.Subject, f.Detail)
}

// Validate 检查四类问题:无主模块、跨多个流对齐团队的人、
// 未登记或方向相反的沟通通道、以及每条模块依赖是否有对应方向的通道。
func (m Map) 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})
	}

	teams := make(map[string]Team, len(m.Teams))
	for _, t := range m.Teams {
		teams[t.ID] = t
	}

	// 一人跨域:同一个人出现在两个流对齐团队里,说明注意力被切开。
	// 平台/赋能团队成员可以出现在业务团队中,这是协作方式,不是拆分。
	person := make(map[string][]string, len(m.Teams)*2)
	for _, t := range m.Teams {
		if t.Kind != StreamAligned {
			continue
		}
		within := make(map[string]bool, len(t.Members))
		for _, p := range t.Members {
			if p == "" || within[p] {
				continue
			}
			within[p] = true
			person[p] = append(person[p], t.ID)
			if len(person[p]) > 1 {
				add("SPLIT_ASSIGNMENT", p, strings.Join(person[p], ", "))
			}
		}
	}

	modules := make(map[string]Module, len(m.Modules))
	for _, mod := range m.Modules {
		modules[mod.Path] = mod
		if mod.Owner == "" {
			add("UNOWNED_MODULE", mod.Path, "no owner team")
			continue
		}
		if _, ok := teams[mod.Owner]; !ok {
			add("UNKNOWN_OWNER", mod.Path, mod.Owner)
		}
	}

	for _, t := range m.Teams {
		for _, target := range t.TalksTo {
			if _, ok := teams[target]; !ok {
				add("UNKNOWN_CHANNEL", t.ID, target)
			}
		}
	}

	// 依赖方向 consumer -> provider 要求通道方向 consumer -> provider。
	// 只有反方向通道时单独报 CHANNEL_DIRECTION_CONFLICT:图上“有箭头”,
	// 但下游无法向上游提需求,这是最难发现的一种错位。
	for _, mod := range m.Modules {
		owner, ok := teams[mod.Owner]
		if !ok {
			continue
		}
		for _, dep := range mod.Imports {
			provider, ok := modules[dep]
			if !ok {
				add("UNKNOWN_DEPENDENCY", mod.Path, dep)
				continue
			}
			providerTeam, ok := teams[provider.Owner]
			if !ok || provider.Owner == mod.Owner {
				continue
			}
			subject := owner.ID + " -> " + providerTeam.ID
			detail := mod.Path + " imports " + dep
			switch {
			case talksTo(owner, providerTeam.ID):
			case talksTo(providerTeam, owner.ID):
				add("CHANNEL_DIRECTION_CONFLICT", subject, detail+"; channel is reversed")
			default:
				add("MISSING_CHANNEL", subject, detail)
			}
		}
	}
	return out
}

func talksTo(t Team, target string) bool {
	for _, id := range t.TalksTo {
		if id == target {
			return true
		}
	}
	return false
}

校验器不解析 Go 源码,Imports 由调用方填入。真实项目里这一步可以接 go list -deps 或 4.2 里的依赖测试,把编译图喂进来;本节保持数据与规则分离,测试才能稳定。它检查的是“声明之间是否自洽”,不是“声明是否符合事实”。如果团队实际不走那条通道,校验器不会知道。

基线干净,变异必须被抓到

测试先构造 arch-lab 的合法映射,断言零违规;再逐条变异,每次只改一个地方,断言对应错误码和主体出现。四个变异分别对应无主模块、一人跨域、缺通道、通道反向。基线数据同时打印规模,防止有人把模块删到校验器“无事可报”。

// file: internal/orgmap/orgmap_test.go
// tier: L1
// verify: go test ./internal/orgmap/
package orgmap

import "testing"

// baseline 是 arch-lab 在 2026-10 的团队-模块映射:五个团队、十一个模块。
func baseline() Map {
	return Map{
		Teams: []Team{
			{ID: "team-order", Kind: StreamAligned, Members: []string{"alice", "bob"}, TalksTo: []string{"team-catalog", "team-inventory", "team-payments", "team-platform"}},
			{ID: "team-catalog", Kind: StreamAligned, Members: []string{"carol"}, TalksTo: []string{"team-platform"}},
			{ID: "team-inventory", Kind: StreamAligned, Members: []string{"dave"}, TalksTo: []string{"team-platform"}},
			{ID: "team-payments", Kind: StreamAligned, Members: []string{"erin"}, TalksTo: []string{"team-platform"}},
			{ID: "team-platform", Kind: Platform, Members: []string{"frank", "grace"}, TalksTo: []string{"team-order", "team-catalog", "team-inventory", "team-payments"}},
		},
		Modules: []Module{
			{Path: "cmd/orderd", Owner: "team-order", Imports: []string{"internal/order", "internal/platform/httpserver", "internal/platform/config", "internal/adapters/postgres"}},
			{Path: "internal/order", Owner: "team-order", Imports: []string{"internal/catalog", "internal/inventory", "internal/payment", "internal/ports", "internal/platform/idempotency"}},
			{Path: "internal/catalog", Owner: "team-catalog"},
			{Path: "internal/inventory", Owner: "team-inventory"},
			{Path: "internal/payment", Owner: "team-payments", Imports: []string{"internal/ports", "internal/platform/idempotency"}},
			{Path: "internal/ports", Owner: "team-platform"},
			{Path: "internal/adapters/memory", Owner: "team-platform", Imports: []string{"internal/ports"}},
			{Path: "internal/adapters/postgres", Owner: "team-platform", Imports: []string{"internal/ports", "internal/platform/config"}},
			{Path: "internal/platform/config", Owner: "team-platform"},
			{Path: "internal/platform/httpserver", Owner: "team-platform", Imports: []string{"internal/platform/config"}},
			{Path: "internal/platform/idempotency", Owner: "team-platform"},
		},
	}
}

func TestBaselineReport(t *testing.T) {
	m := baseline()
	owner := make(map[string]string, len(m.Modules))
	for _, mod := range m.Modules {
		owner[mod.Path] = mod.Owner
	}
	imports, cross := 0, 0
	for _, mod := range m.Modules {
		for _, dep := range mod.Imports {
			imports++
			if owner[dep] != mod.Owner {
				cross++
			}
		}
	}
	findings := m.Validate()
	t.Logf("teams=%d modules=%d imports=%d cross_team=%d findings=%d",
		len(m.Teams), len(m.Modules), imports, cross, len(findings))
	if len(findings) != 0 {
		t.Fatalf("baseline should be clean, got %v", findings)
	}
}

func TestValidateCatchesMappingsThatDrift(t *testing.T) {
	cases := []struct {
		name    string
		mutate  func(*Map)
		code    string
		subject string
	}{
		{"unowned module", func(m *Map) { m.Modules[modIndex(m, "internal/catalog")].Owner = "" }, "UNOWNED_MODULE", "internal/catalog"},
		{"one person, two stream teams", func(m *Map) {
			i := teamIndex(m, "team-catalog")
			m.Teams[i].Members = append(m.Teams[i].Members, "alice")
		}, "SPLIT_ASSIGNMENT", "alice"},
		{"dependency without channel", func(m *Map) {
			i := teamIndex(m, "team-order")
			m.Teams[i].TalksTo = remove(m.Teams[i].TalksTo, "team-inventory")
		}, "MISSING_CHANNEL", "team-order -> team-inventory"},
		{"channel against dependency", func(m *Map) {
			i := teamIndex(m, "team-order")
			m.Teams[i].TalksTo = remove(m.Teams[i].TalksTo, "team-catalog")
			j := teamIndex(m, "team-catalog")
			m.Teams[j].TalksTo = append(m.Teams[j].TalksTo, "team-order")
		}, "CHANNEL_DIRECTION_CONFLICT", "team-order -> team-catalog"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			m := baseline()
			tc.mutate(&m)
			if !has(m.Validate(), tc.code, tc.subject) {
				t.Fatalf("want %s on %q, got %v", tc.code, tc.subject, m.Validate())
			}
		})
	}
}

func modIndex(m *Map, path string) int {
	for i := range m.Modules {
		if m.Modules[i].Path == path {
			return i
		}
	}
	panic(path)
}

func teamIndex(m *Map, id string) int {
	for i := range m.Teams {
		if m.Teams[i].ID == id {
			return i
		}
	}
	panic(id)
}

func remove(values []string, want string) []string {
	out := values[:0]
	for _, v := range values {
		if v != want {
			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 1.26.0 下执行:

$ go test ./internal/orgmap/ -run TestBaselineReport -v
=== RUN   TestBaselineReport
    orgmap_test.go:47: teams=5 modules=11 imports=15 cross_team=10 findings=0
--- PASS: TestBaselineReport (0.00s)
PASS
ok  	archlab/internal/orgmap	0.231s

基线是五个团队、十一个模块、十五条 import 边,其中十条跨团队,归并为五对团队依赖,零违规。完整测试还包含四个变异用例,全部通过;gofmt -l 无输出,go vet ./... 无告警。这里的数字可以直接当回归基线:模块数增加而跨团队边不增加,说明新代码还在原边界内;跨团队边突然增加,就要问是否新增了未登记的通道。

反向使用康威定律

如果下一步要把 inventory 拆成独立服务,顺序不应该先是“建仓库、拆包、上线”,而应该是先确认三件事:inventory 有独立的值班人;order → inventory 有稳定的请求通道和响应时限;库存规则变更不再需要 order 团队评审。三件事到位后,拆包只是把已经存在的边界写进目录。反过来,先拆包再补组织,结果通常是新服务仍然由 order 团队半夜修改,只是多了一次网络调用。

代码检查和团队调整要配合使用。代码检查能在一到两个发布周期内维持边界,给组织调整争取时间;组织调整完成后,检查规则可以放宽,因为通道已经变成日常习惯。只做前者,会得到一张漂亮但没人遵守的依赖图;只做后者,过渡期没有任何护栏。8.3 的适应度函数负责前者,本节的组织映射负责后者,两者不是替代关系。

常见错误与边界

第一,把康威定律当宿命论。组织完全不动,却要求架构“先解耦”,最后只能靠更复杂的审批维持,成本会转移到交付速度上。第二,按汇报线而不是按变更责任画团队映射。汇报线回答“谁给谁打分”,映射表要回答“谁有权改这块代码、谁必须被通知”。

第三,用无向图记录沟通。无向图会把“平台能发公告”误判成“业务团队能提单”,恰好掩盖最需要修的反向错位。第四,把校验器当组织绩效工具。成员表和通道会过期,季度校准一次,比每天报警更有用;数据不更新时,校验器只会制造噪音。

边界也要说清楚:外包团队、社区贡献者和跨公司接口不在这个模型里。他们的承诺方式不同,不能套用同一张通道表;硬套会让模型看起来完整,实际把最不确定的部分隐藏了。

小结

康威定律的价值不是一句“组织决定架构”,而是提供一张可以对照的表:团队拥有什么、依赖往哪个方向走、通道是否同向。arch-lab 用五个团队、十一个模块和十条跨团队依赖把这张表落成数据,internal/orgmap 把无主模块、一人跨域、缺通道和反向通道变成四类可测试的违规。基线零违规、四个变异全部被抓住,说明规则本身是有效的;规则之外的组织意愿,仍然要靠 9.2 的决策权和契约来固定。

阅读导航:上一节:8.3 架构适应度函数:把边界写成测试 · 下一节:9.2 架构决策权与团队契约 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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