《后端架构设计·架构基础与边界》1.3 架构风格谱系与 arch-lab 选型矩阵

用团队规模、独立部署、数据一致性、故障域、演进成本、运维复杂度与订单系统适配度七个维度对比五种架构风格,给出 arch-lab 的 ADR-001 与被否方案,并用可运行的 Go 依赖检查器把包结构规则固化成可执行命令,说明它只是护栏而非证明。

五种风格,不是五级成熟度

单体、模块化单体、微服务、事件驱动、Serverless 经常被排成一条“演进阶梯”,仿佛项目长大后必须逐级往上爬。这是误解。它们是五种不同的约束组合:把复杂度放在哪里、让什么独立变化、接受哪种失败模式。一个十人团队用微服务可能比单体更慢;一个日订单百万的系统用单进程也可能活得很好,只要它能水平复制且数据库不是瓶颈。

选型要先承认约束。arch-lab 在卷一开局时的真实约束是:一个两人团队、日订单量不到万级、订单与库存必须同成败、没有独立运维预算、但希望模块边界清晰以便后续拆分。下面用固定维度比较,而不是凭印象打分。

选型矩阵

维度单体模块化单体微服务事件驱动Serverless
团队规模1–3 人3–15 人每服务 2–5 人,总量 15+跨团队,需平台组1–5 人
独立部署整体发布同进程,按模块开关每个服务独立消费者可独立函数级
数据一致性单库强一致单库或单写库强一致分布式事务或最终一致最终一致,靠事件依赖外部存储
故障域全站全进程按服务隔离按消费组隔离按调用隔离
演进成本低,但全局耦合中,模块边界可渐进高,契约与版本管理高,事件 schema 演进中,冷启动与本地调试
运维复杂度低低到中高,需服务发现与追踪高,需消息平台与对账中到高,需函数平台
订单系统适配度原型与演示卷一默认团队与流量到位后支付后异步链路峰值突发、无长连接

矩阵里没有“更好”,只有“在当前约束下更合适”。微服务在“独立部署”和“故障域”两列明显占优,但代价落在“一致性”和“运维复杂度”;事件驱动把耦合换成最终一致,换来的是对账与可观测性成本;Serverless 的函数级弹性很诱人,但订单服务需要长连接、事务和稳定的本地调试体验,卷一的适配度并不高。

还要注意矩阵的读法:先看团队规模与运维复杂度这两列,它们通常直接淘汰一半选项;再看数据一致性与故障域,它们决定事故的形状;最后才看演进成本。反过来读,容易先被“先进架构”吸引,再为它补团队和预算。

ADR-001:选择模块化单体

ADR-001:arch-lab 卷一采用模块化单体
状态:已接受
背景:两人团队,日订单量不到万级;订单、库存、支付共享同一套状态机
      与事务边界;没有独立运维多个服务的预算。
决策:单进程部署,按 internal/order、catalog、payment、inventory、
      ports、adapters、platform 划分模块;模块间只经接口与领域类型通信;
      依赖方向由 deplint 强制检查。
备选 A:微服务——按订单、库存、支付拆三个服务。
      否决理由:跨服务下单需要分布式事务或补偿;当前团队无法承担服务发现、
      链路追踪、独立发布与值班成本;一致性缺陷会直接变成超卖。
备选 B:事件驱动优先——下单只写事件,库存与支付各自消费。
      否决理由:卷一需要同步返回订单状态;事件溯源、幂等消费与对账尚未建立,
      调试成本高于收益。
后果:正面——单库事务保证状态与库存同成败,重构可先改模块边界;
      负面——无法按模块独立扩缩容,单进程故障影响全部模块;
      重新评估触发条件——单模块团队超过 5 人、某模块需要独立扩缩容,
      或发布频率冲突开始排队。

这份 ADR 的关键不是“选了模块化单体”,而是写清了什么情况下应该推翻它。如果库存模块先独立扩缩容,或者支付团队要求独立发布,模块化单体就不再是默认答案。届时拆分不是从零开始,因为包边界已经存在,deplint 也已经把依赖方向固定下来。

包结构与依赖方向

cmd/orderd/main.go
internal/order/           聚合、状态机、领域事件
internal/catalog/         SKU 与价格
internal/payment/         支付端口与假实现
internal/inventory/       库存预留与释放
internal/ports/           仓储/支付/消息接口
internal/adapters/memory/
internal/adapters/postgres/
internal/platform/config/
internal/platform/httpserver/
internal/platform/idempotency/

只有目录结构还不够。模块化单体最常见的退化,是半年后 internal/order 直接 import 了 internal/adapters/postgres,领域层开始拼 SQL,边界名存实亡。所以需要一条明确的依赖方向规则:

包允许依赖禁止依赖
internal/order、internal/catalog标准库adapters、platform、cmd
internal/portsorder、catalogadapters、platform
internal/adaptersorder、catalog、ports、platformcmd
internal/platform标准库order、ports、adapters
cmd任意 internal/*—

方向是“外层指向内层”:cmd 组装一切,adapters 实现 ports,ports 引用领域类型,领域包只依赖标准库。platform 被当作叶子:配置、HTTP 服务器、幂等组件可以被适配器和 cmd 使用,但不能反向依赖领域。这条规则有一个容易忽略的推论:ports 可以 import order,但 order 不能 import ports,否则立刻成环。

把规则变成可执行检查

目录约定和口头规则都会被违反,所以把它写成检查器。下面用 go/parser 的 ImportsOnly 模式扫描模块内的非测试文件:只解析 import 块,不做类型检查,因此速度足够放进 CI,也不会被构建标签干扰。

// file: tools/deplint/lint.go
// tier: L1
// verify: go test ./tools/deplint/
package main

import (
	"go/parser"
	"go/token"
	"io/fs"
	"path/filepath"
	"strconv"
	"strings"
)

type Rule struct {
	From  string
	Allow []string
}

type Violation struct {
	File   string
	Import string
	Rule   string
}

// Check 扫描非测试 .go 文件,只检查模块内导入。
func Check(root, module string, rules []Rule) ([]Violation, error) {
	modulePrefix := module + "/"
	fset := token.NewFileSet()
	var violations []Violation

	err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
		if walkErr != nil {
			return walkErr
		}
		if entry.IsDir() {
			name := entry.Name()
			if path != root && (name == "testdata" || name == "vendor" || strings.HasPrefix(name, ".")) {
				return fs.SkipDir
			}
			return nil
		}
		if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
			return nil
		}

		file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
		if err != nil {
			return err
		}
		pkgDir, err := filepath.Rel(root, filepath.Dir(path))
		if err != nil {
			return err
		}
		pkg := module
		if pkgDir != "." {
			pkg = module + "/" + filepath.ToSlash(pkgDir)
		}
		rule, matched := matchRule(pkg, rules)
		if !matched {
			return nil
		}
		fileName, err := filepath.Rel(root, path)
		if err != nil {
			return err
		}
		for _, spec := range file.Imports {
			importPath, err := strconv.Unquote(spec.Path.Value)
			if err != nil {
				return err
			}
			if importPath != module && !strings.HasPrefix(importPath, modulePrefix) {
				continue
			}
			if !allowedImport(importPath, rule.Allow) {
				violations = append(violations, Violation{
					File: filepath.ToSlash(fileName), Import: importPath, Rule: rule.From,
				})
			}
		}
		return nil
	})
	return violations, err
}

// matchRule 取最长前缀,让 internal/adapters 覆盖更宽的 internal 规则。
func matchRule(pkg string, rules []Rule) (Rule, bool) {
	var best Rule
	found := false
	for _, rule := range rules {
		if pkg != rule.From && !strings.HasPrefix(pkg, rule.From+"/") {
			continue
		}
		if !found || len(rule.From) > len(best.From) {
			best, found = rule, true
		}
	}
	return best, found
}

func allowedImport(importPath string, allow []string) bool {
	for _, prefix := range allow {
		if importPath == prefix || strings.HasPrefix(importPath, prefix+"/") {
			return true
		}
	}
	return false
}

规则表与 CLI 入口放在同一个 main 包里。注意 internal/adapters 的规则会匹配它的所有子包,matchRule 用最长前缀保证更具体的规则优先:

// file: tools/deplint/main.go
// tier: L1
// verify: go run ./tools/deplint -root . -module archlab
package main

import (
	"flag"
	"fmt"
	"os"
)

// rules 是 arch-lab 的依赖方向契约:内层不知道外层,平台不知道领域。
var rules = []Rule{
	{From: "archlab/internal/order", Allow: nil},
	{From: "archlab/internal/catalog", Allow: nil},
	{From: "archlab/internal/ports", Allow: []string{"archlab/internal/order", "archlab/internal/catalog"}},
	{From: "archlab/internal/adapters", Allow: []string{
		"archlab/internal/order", "archlab/internal/catalog",
		"archlab/internal/ports", "archlab/internal/platform",
	}},
	{From: "archlab/internal/platform", Allow: nil},
	{From: "archlab/cmd", Allow: []string{"archlab/internal"}},
}

func main() {
	root := flag.String("root", ".", "module root")
	module := flag.String("module", "archlab", "module path from go.mod")
	flag.Parse()

	violations, err := Check(*root, *module, rules)
	if err != nil {
		fmt.Fprintln(os.Stderr, "deplint:", err)
		os.Exit(2)
	}
	for _, violation := range violations {
		fmt.Printf("%s: imports %s (rule %s)\n", violation.File, violation.Import, violation.Rule)
	}
	if len(violations) > 0 {
		fmt.Fprintf(os.Stderr, "deplint: %d violation(s)\n", len(violations))
		os.Exit(1)
	}
	fmt.Println("deplint: ok")
}

测试在一个临时模块里放四个文件:领域包错误地 import 适配器、平台包错误地 import 领域、适配器合法地 import 领域、cmd 合法地 import 适配器。它断言恰好两条违规,从而证明检查器既能抓到越界,也不会误伤合法边:

// file: tools/deplint/lint_test.go
// tier: L1
// verify: go test ./tools/deplint/ -run TestCheckFlagsLayerViolations -v
package main

import (
	"os"
	"path/filepath"
	"testing"
)

func writeFile(t *testing.T, root, name, body string) {
	t.Helper()
	path := filepath.Join(root, filepath.FromSlash(name))
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
}

func TestCheckFlagsLayerViolations(t *testing.T) {
	root := t.TempDir()
	writeFile(t, root, "internal/order/order.go", `package order

import _ "archlab/internal/adapters/memory"
`)
	writeFile(t, root, "internal/platform/config/config.go", `package config

import _ "archlab/internal/order"
`)
	writeFile(t, root, "internal/adapters/memory/memory.go", `package memory

import (
	_ "archlab/internal/order"
	_ "archlab/internal/ports"
)
`)
	writeFile(t, root, "cmd/orderd/main.go", `package main

import _ "archlab/internal/adapters/memory"

func main() {}
`)
	// 测试文件不在契约范围内:允许用假实现替换端口。
	writeFile(t, root, "internal/order/order_test.go", `package order

import _ "archlab/internal/adapters/memory"
`)

	violations, err := Check(root, "archlab", rules)
	if err != nil {
		t.Fatal(err)
	}
	if len(violations) != 2 {
		t.Fatalf("violations = %d, want 2: %+v", len(violations), violations)
	}

	got := map[string]string{}
	for _, violation := range violations {
		got[violation.File] = violation.Import
	}
	if got["internal/order/order.go"] != "archlab/internal/adapters/memory" {
		t.Errorf("missing domain -> adapter violation: %+v", got)
	}
	if got["internal/platform/config/config.go"] != "archlab/internal/order" {
		t.Errorf("missing platform -> domain violation: %+v", got)
	}
}

真实运行结果

以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),在 .local 的临时模块中执行,未在 CI 复跑:

$ go test ./tools/deplint/ -count=1 -v
=== RUN   TestCheckFlagsLayerViolations
--- PASS: TestCheckFlagsLayerViolations (0.05s)
PASS
ok  	archlab/tools/deplint	0.343s

$ go run ./tools/deplint -root . -module archlab
deplint: ok

再用一个故意违规的临时模块验证失败路径:

$ go run ./tools/deplint -root ./badfixture -module archlab
internal/order/order.go: imports archlab/internal/adapters (rule archlab/internal/order)
internal/platform/config.go: imports archlab/internal/order (rule archlab/internal/platform)
deplint: 2 violation(s)

这就是“可验证约束”的样子:规则不是评审时的口头提醒,而是一条会返回非零退出码的命令。它也有明确边界:只检查模块内 import,不检查测试文件,不做类型检查,也抓不到通过反射或接口注册产生的运行时依赖。它是护栏,不是证明。

交接到第 2 章

选型矩阵回答了“为什么是模块化单体”,依赖检查器回答了“怎么保证边界不被侵蚀”。但还有一个问题没答:这些模块在运行时如何协作、部署单元长什么样、一次下单请求穿过哪些边界。第 2 章用 C4 的四级视图把 arch-lab 从代码结构讲到容器与部署,先看系统上下文,再看容器、组件和代码。

常见错误与边界

第一,把“微服务”当成团队成熟的标志。服务拆得越细,跨服务一致性、可观测性和发布协调的成本越高;团队没有对应编制时,拆分只会把单体的问题变成分布式问题。

第二,用架构图代替依赖规则。图上画了箭头,代码里没有检查,半年后箭头就反了。

第三,让 deplint 检查第三方依赖。它只应约束模块内方向;外部库的选型是另一类决策,混在一起会让规则表迅速膨胀。

第四,把测试文件也纳入检查。测试需要用假实现替换端口,强行套用生产依赖规则会逼出更多别扭的抽象。

第五,规则粒度太细。按每个包写一条规则,维护成本高于收益;按层写前缀规则,再用最长前缀处理例外,通常足够。

第六,忽略 ADR 的重新评估条件。模块化单体不是终局,写清“什么时候应该拆”比写清“为什么现在不拆”更重要。

小结

五种架构风格是五种约束组合,不是成熟度阶梯。arch-lab 在卷一选择模块化单体:单库事务保证订单与库存同成败,包边界保留未来拆分的可能,deplint 把依赖方向变成可执行命令。ADR-001 同时记录了微服务与事件驱动优先被否决的理由,以及重新评估的触发条件。下一章进入 C4 视图,回答这些模块在运行时如何组成一个可部署的系统。

阅读导航:上一节:1.2 质量属性与无法回避的取舍 · 下一节:2.1 arch-lab 的四级视图(C4) 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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