边界不是画出来的,是校验出来的
第 1 章已经用选型矩阵说明 arch-lab 为什么选模块化单体,也用 tools/deplint 固定了包前缀层面的依赖方向。这一节不重复那场比较,只回答一个更具体的问题:一个模块化单体怎样组织,才能在需要时真的拆出一个服务,而不是在拆分当天才发现所有代码缠在一起。站内 模块化单体架构
已经讲过业务边界与单体演进的一般思路,这里补的是 arch-lab 里可执行的模块描述与校验。
“模块化”最常见的失败方式,是把目录改名为 order、inventory、payment,然后所有包继续互相 import。目录只是命名,边界必须同时约束四件事:谁能被别的模块 import、谁拥有哪张表、跨模块调用走哪个接口、模块何时可以退役。这四件事都能写成数据,也就能被测试和 CI 检查。
模块清单:先写清楚谁拥有什么
arch-lab 卷一冻结了五个业务模块和两个技术模块。模块不是包的同义词:一个模块可以拥有多个包,一个包只能属于一个模块。
| 模块 | Owner | 公开包 | 私有包 | 拥有的数据 | 允许依赖 |
|---|---|---|---|---|---|
| order | team-order | internal/order | internal/order/app | orders、order_items | 无 |
| catalog | team-order | internal/catalog | internal/catalog/app | skus、prices | 无 |
| inventory | team-stock | internal/inventory | internal/inventory/app | inventory、reservations | order、ports |
| payment | team-pay | internal/payment | internal/payment/app | payments | order、ports |
| ports | team-order | internal/ports | — | 无 | order、catalog |
| adapters | platform | — | internal/adapters/* | 无(映射外部存储) | order、catalog、ports、platform |
| platform | platform | — | 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 的落地:依赖测试与包边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。