五种风格,不是五级成熟度
单体、模块化单体、微服务、事件驱动、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/ports | order、catalog | adapters、platform |
internal/adapters | order、catalog、ports、platform | cmd |
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) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。