《后端架构设计·架构基础与边界》4.1 模块化单体:先拿到边界再谈拆分

模块化单体的关键不是目录分层,而是把边界写成可校验的数据:模块清单、公开包、私有包、数据所有权与依赖方向。本篇给出 arch-lab 的模块描述符与 Go 校验器,检查依赖成环、私有包越界与 internal 包无主,并预演库存模块独立部署时需要移动的包、改动的接口与数据库切分步骤。

边界不是画出来的,是校验出来的

第 1 章已经用选型矩阵说明 arch-lab 为什么选模块化单体,也用 tools/deplint 固定了包前缀层面的依赖方向。这一节不重复那场比较,只回答一个更具体的问题:一个模块化单体怎样组织,才能在需要时真的拆出一个服务,而不是在拆分当天才发现所有代码缠在一起。站内 模块化单体架构 已经讲过业务边界与单体演进的一般思路,这里补的是 arch-lab 里可执行的模块描述与校验。

“模块化”最常见的失败方式,是把目录改名为 order、inventory、payment,然后所有包继续互相 import。目录只是命名,边界必须同时约束四件事:谁能被别的模块 import、谁拥有哪张表、跨模块调用走哪个接口、模块何时可以退役。这四件事都能写成数据,也就能被测试和 CI 检查。

模块清单:先写清楚谁拥有什么

arch-lab 卷一冻结了五个业务模块和两个技术模块。模块不是包的同义词:一个模块可以拥有多个包,一个包只能属于一个模块。

模块Owner公开包私有包拥有的数据允许依赖
orderteam-orderinternal/orderinternal/order/apporders、order_items无
catalogteam-orderinternal/cataloginternal/catalog/appskus、prices无
inventoryteam-stockinternal/inventoryinternal/inventory/appinventory、reservationsorder、ports
paymentteam-payinternal/paymentinternal/payment/apppaymentsorder、ports
portsteam-orderinternal/ports—无order、catalog
adaptersplatform—internal/adapters/*无(映射外部存储)order、catalog、ports、platform
platformplatform—internal/platform/*无无

表里有两列容易被忽略。第一列是 Owner:模块必须有值班团队,否则边界争执时没人能拍板。第五列是数据所有权:reservations 只归 inventory,order 模块不能直接写它;order 需要预留库存时,只能调用 inventory 的公开接口。数据所有权比包依赖更难伪造,因为跨模块 JOIN 一旦出现,拆分时就要动 SQL。

order 与 catalog 的领域类型放在公开包里,因为 ports 和 adapters 需要引用它们;order/app 这类应用服务是私有的,其他模块只能通过公开 facade 调用。公开包是承诺,私有包是自由,二者的区别必须在校验器里体现。

模块描述符:把边界写成数据

先定义描述符。它只描述事实,不执行任何逻辑。下面是一个完整、可编译的文件,包含描述符、结构检查、import 扫描、归属覆盖率与环检测:

// file: internal/modules/modules.go
// tier: L1
// verify: go test ./internal/modules/ -run TestValidate -v
package modules

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

// Module 是模块边界声明。PublicPackages 允许跨模块导入;
// InternalPackages 只允许本模块导入;Owner 标明值班与评审归属。
type Module struct {
	Name             string
	Owner            string
	PublicPackages   []string
	InternalPackages []string
	DependsOn        []string
}

type Registry struct {
	Modules []Module
}

type Checker struct {
	ModulePath string
	Exempt     []string // 不归属任何模块的包前缀,例如检查器自身
}

func (c Checker) Validate(root string, reg Registry) ([]string, error) {
	var out []string
	byName := make(map[string]Module)
	owner := make(map[string]string)
	private := make(map[string]string)

	for _, m := range reg.Modules {
		if _, exists := byName[m.Name]; exists {
			out = append(out, "duplicate module "+m.Name)
			continue
		}
		byName[m.Name] = m
		for _, pkg := range m.PublicPackages {
			c.claim(&out, owner, m.Name, pkg)
		}
		for _, pkg := range m.InternalPackages {
			c.claim(&out, owner, m.Name, pkg)
			private[pkg] = m.Name
		}
	}
	for _, m := range reg.Modules {
		for _, dep := range m.DependsOn {
			if _, ok := byName[dep]; !ok {
				out = append(out, fmt.Sprintf("module %q: unknown dependency %q", m.Name, dep))
			}
		}
	}
	out = append(out, cycles(reg)...)

	fset := token.NewFileSet()
	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
		}
		src := lookup(owner, c.pkgPath(root, filepath.Dir(path)))
		if src == "" {
			return nil
		}
		for _, spec := range file.Imports {
			imported, err := strconv.Unquote(spec.Path.Value)
			if err != nil || !has(imported, c.ModulePath) {
				continue
			}
			dst := lookup(owner, imported)
			if dst == "" || dst == src {
				continue
			}
			for prefix, prefixOwner := range private {
				if prefixOwner != src && has(imported, prefix) {
					out = append(out, fmt.Sprintf("%s: module %q imports internal %q of %q",
						rel(root, path), src, imported, prefixOwner))
					dst = ""
					break
				}
			}
			if dst != "" && !hasDep(byName[src], dst) {
				out = append(out, fmt.Sprintf("%s: module %q imports %q without declaring %q",
					rel(root, path), src, imported, dst))
			}
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	out = append(out, c.coverage(root, owner)...)
	sort.Strings(out)
	return out, nil
}

func (c Checker) claim(out *[]string, owner map[string]string, module, pkg string) {
	if !has(pkg, c.ModulePath) {
		*out = append(*out, fmt.Sprintf("module %q claims package %q outside %q", module, pkg, c.ModulePath))
		return
	}
	if previous, ok := owner[pkg]; ok && previous != module {
		*out = append(*out, fmt.Sprintf("package %q claimed by %q and %q", pkg, previous, module))
		return
	}
	owner[pkg] = module
}

// coverage 要求 internal/ 下每个含非测试 Go 文件的包恰好被声明一次。
func (c Checker) coverage(root string, owner map[string]string) []string {
	var out []string
	_ = filepath.WalkDir(filepath.Join(root, "internal"), func(path string, entry fs.DirEntry, err error) error {
		if err != nil || !entry.IsDir() {
			return nil
		}
		pkg := c.pkgPath(root, path)
		for _, exempt := range c.Exempt {
			if has(pkg, exempt) {
				return nil
			}
		}
		entries, err := os.ReadDir(path)
		if err != nil {
			return nil
		}
		for _, item := range entries {
			name := item.Name()
			if !item.IsDir() && strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go") {
				if _, ok := owner[pkg]; !ok {
					out = append(out, fmt.Sprintf("internal package %q is unowned", pkg))
				}
				return nil
			}
		}
		return nil
	})
	return out
}

func (c Checker) pkgPath(root, dir string) string {
	value, err := filepath.Rel(root, dir)
	if err != nil || value == "." {
		return c.ModulePath
	}
	return c.ModulePath + "/" + filepath.ToSlash(value)
}

func lookup(owner map[string]string, pkg string) string {
	if module, ok := owner[pkg]; ok {
		return module
	}
	best, bestLen := "", -1
	for prefix, module := range owner {
		if has(pkg, prefix) && len(prefix) > bestLen {
			best, bestLen = module, len(prefix)
		}
	}
	return best
}

// cycles 用 Kahn 拓扑排序检测模块级依赖环,剩余节点即环或其下游。
func cycles(reg Registry) []string {
	indegree := make(map[string]int)
	edges := make(map[string][]string)
	for _, m := range reg.Modules {
		indegree[m.Name] = 0
	}
	for _, m := range reg.Modules {
		for _, dep := range m.DependsOn {
			if _, ok := indegree[dep]; !ok {
				continue
			}
			edges[m.Name] = append(edges[m.Name], dep)
			indegree[dep]++
		}
	}
	var queue []string
	for name, degree := range indegree {
		if degree == 0 {
			queue = append(queue, name)
		}
	}
	seen := 0
	for len(queue) > 0 {
		name := queue[0]
		queue = queue[1:]
		seen++
		for _, next := range edges[name] {
			indegree[next]--
			if indegree[next] == 0 {
				queue = append(queue, next)
			}
		}
	}
	if seen == len(indegree) {
		return nil
	}
	var stuck []string
	for name, degree := range indegree {
		if degree > 0 {
			stuck = append(stuck, name)
		}
	}
	sort.Strings(stuck)
	return []string{"dependency cycle among: " + strings.Join(stuck, ", ")}
}

func hasDep(m Module, target string) bool {
	for _, dep := range m.DependsOn {
		if dep == target {
			return true
		}
	}
	return false
}

func has(pkg, prefix string) bool {
	return pkg == prefix || strings.HasPrefix(pkg, prefix+"/")
}

func rel(root, path string) string {
	value, err := filepath.Rel(root, path)
	if err != nil {
		return path
	}
	return filepath.ToSlash(value)
}

校验器同时回答三个问题:模块依赖图有没有环;有没有模块 import 了别人的私有包;internal/ 下有没有包没被任何模块认领。第一条防止“互相依赖”,第二条防止“绕过 facade”,第三条防止新目录悄悄绕过治理。它和 1.3 的 deplint 分工不同:deplint 管包前缀的层间方向,这里管模块归属与模块级依赖,两者可以同时进 CI。

用测试固定规则

测试用临时目录构造四类文件:合法依赖、私有包越界、未声明依赖、无主 internal 包。断言不是“没有报错”,而是“恰好报出这三条”:

// file: internal/modules/modules_test.go
// tier: L1
// verify: go test ./internal/modules/ -run TestValidate -v
package modules

import (
	"strings"
	"testing"
)

func TestValidateRejectsDependencyCycle(t *testing.T) {
	reg := Registry{Modules: []Module{
		{Name: "a", InternalPackages: []string{"archlab/internal/a"}, DependsOn: []string{"b"}},
		{Name: "b", InternalPackages: []string{"archlab/internal/b"}, DependsOn: []string{"a"}},
	}}
	problems, err := Checker{ModulePath: "archlab"}.Validate(t.TempDir(), reg)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(strings.Join(problems, "\n"), "dependency cycle among: a, b") {
		t.Fatalf("problems = %v", problems)
	}
}

真实运行结果

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

$ go test ./internal/modules/ -run TestValidate -v -count=1
=== RUN   TestValidateRejectsDependencyCycle
--- PASS: TestValidateRejectsDependencyCycle (0.00s)
PASS
ok  	archlab/internal/modules	0.551s

这里没有“跑通就算数”。这个测试故意让 a 依赖 b、b 依赖 a;断言环被报出,证明检查器不是只会对合法输入点头。完整测试还覆盖私有包越界、未声明依赖与无主 internal 包。

模块化单体与大泥球的四个区别

维度大泥球模块化单体
公共接口任何包都能 import 任何包跨模块只碰 PublicPackages,facade 之外不承诺稳定
数据所有权表谁都能写,JOIN 随处出现订单表归 order,预留表归 inventory,跨模块只能走接口
模块级测试通常只能跑全系统集成测试go test ./internal/inventory/... 可独立运行
迁移路径公开接口、数据、部署配置缠在一起三者可以一起搬走,拆分不必等于重写

拆分预演:库存模块独立部署

假设库存团队从 1 人扩到 6 人,发布频率与订单团队冲突,需要把 inventory 拆成独立服务。预演不是写一份理想架构图,而是列出会疼的地方与回退条件。

步骤动作完成判据回退条件
1把 internal/inventory 与 internal/inventory/app 移到独立仓库或 services/inventory模块校验器仍通过,order 只经 ports.InventoryPort 调用出现 order 直接 import inventory 私有包
2将 InventoryPort 的进程内实现替换为 HTTP/gRPC 客户端适配器契约测试对客户端与服务端同时通过错误语义无法映射为 ErrNotFound/ErrConflict/ErrUnavailable
3拆出 inventory、reservations 两张表到独立库双写或 CDC 校验连续 7 天无差异预留与释放出现跨库不一致
4把同步预留改成“预留 + 超时释放”,订单侧接受最终一致压测下超卖为 0,补偿任务可重放订单状态机无法表达 RESERVATION_PENDING
5切流量,保留旧实现一个发布周期错误率、P99、对账差值为零任一指标超阈值,按开关回切

第 2 步是最容易被低估的:进程内调用时,InventoryPort 的方法可以同步返回;变成远程调用后,超时、重试、幂等和部分失败都会出现。接口签名可能不变,但语义变了,所以契约测试必须同时跑客户端与服务端。第 4 步则要求订单状态机新增中间状态;如果领域模型拒绝这个状态,说明拆分时机还没到。

回退条件要提前写死。比如“双写差异连续 3 天大于 0.01% 就回退”,而不是等事故发生后再讨论。没有回退条件的拆分预演只是愿望清单。

模块生命周期

模块不是永久标签,它有生命周期:提议、活跃、弃用、退役。提议阶段要有 Owner、公开接口草案和数据所有权;活跃阶段每次修改公开包都要走契约评审;弃用阶段先停止新增跨模块调用,再把调用方迁到替代模块;退役阶段删除公开包、迁移数据,并从 Registry 中移除。校验器能检查“还有谁在 import 这个包”,但“该不该退役”仍是人的判断。生命周期写进 ADR,比写在 wiki 里更难被忽略。

常见错误与边界

第一,把 PublicPackages 当成“所有非 internal 包”。Go 的 internal/ 是编译器强制的目录规则,模块的公开/私有是团队约定,两者不是一回事;描述符必须显式列出。

第二,只检查 import,不检查数据访问。模块可以声明不依赖 inventory,却在 SQL 里 JOIN reservations;这需要数据库账号权限或 schema 评审补位,静态 import 检查看不到。

第三,让校验器承担架构评价。它能证明“没有越界 import”,不能证明“模块划分合理”。一个所有模块都依赖 order 的设计可能完全合规,但仍然是耦合的。

第四,忽略测试文件的边界。测试可以 import 适配器来构造假实现,但不应把测试辅助包放进生产依赖图;_test.go 被排除在扫描外,正是为了保留这个自由度。

小结

模块化单体的交付物不是一张目录图,而是一组可校验的声明:模块、Owner、公开包、私有包、依赖和自有数据。arch-lab 用 internal/modules 把这组声明变成 Go 结构体,再用一个基于 go/parser 的校验器检查成环、私有包越界和无主 internal 包。拆分预演则把“以后再说”变成有步骤、有判据、有回退条件的清单。下一节进入端口与适配器,看看模块之间真正被调用的接口如何用契约测试固定下来。

阅读导航:上一节:3.3 聚合、事务边界与一致性边界 · 下一节:4.2 端口与适配器在 arch-lab 的落地:依赖测试与包边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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