《后端架构设计·架构基础与边界》12.2 初始架构与模块划分

卷一收口第二步,把 arch-lab 的模块清单、依赖方向、部署单元与数据 owner 冻成一份带指纹的快照。本篇给出可机器读取的 Go 结构、三者闭合校验器和黄金指纹测试,并说明唯一运行时单元、单库数据所有权与 PostgreSQL L3 核对步骤。

基线快照不是架构图

2.1 用 C4 画过容器与组件,4.1 定过模块边界,6.1 定过数据所有权,9.1 把模块映射到团队。这四份材料各自正确,合在一起却可能互相矛盾:某个模块没有部署单元,某张表被两个模块声明,某个 owner 已经不在值班名册。

12.2 把这些事实冻结成一份快照:模块、依赖方向、部署单元、数据 owner 一次性对齐,并计算内容指纹。快照不是图,而是可 diff、可校验、可回滚的数据。

模块清单与依赖方向

模块Owner部署单元自有数据允许依赖
orderteam-orderorderdorders、order_items无
catalogteam-orderorderdskus、prices无
inventoryteam-stockorderdinventory、reservationsorder、ports
paymentteam-payorderdpaymentsorder、ports
portsteam-orderorderd无order、catalog
adaptersplatformorderd无order、catalog、ports、platform
platformplatformorderd无无

依赖方向只允许从上表右侧指向左侧的同层或更低层。成环检查不在这里重复:4.1 的 internal/modules 已经用 go/parser 扫描 import 并做过环检测,本节的校验器只回答“模块、部署与数据是否对得上”。

部署单元与数据 owner

部署单元类型副本内容说明
orderdruntime3七个模块唯一可部署二进制,无状态,前面挂负载均衡
pg-primarydata1七张表PostgreSQL 18.4,按模块划分 schema 前缀而不是独立库

卷一只有一个运行时单元,这是 1.3 ADR 的直接后果。好处是跨模块事务仍在本地事务里完成;代价是任何模块的 panic 或内存泄漏都会带走整个进程,爆炸半径等于全部业务。这个代价写进 12.3 的风险台账,不在这里用“以后拆”掩盖。

数据 owner 指模块,不是 DBA。orders 只允许 order 模块写,inventory 需要读取订单状态时调用 order 的公开接口,不能跨模块 JOIN。L3 验证时把 owner 写进表注释 owner=order,让数据库自己留下可核对的证据。

三者一致的三条规则

  1. 每个模块必须恰好属于一个 runtime 部署单元,且该单元的 modules 列表包含它。
  2. 每张表只能有一个 owner 模块,owner 必须存在、必须声明这张表,表必须落在 data 单元。
  3. 模块声明的每张表都必须有存储条目,且存储条目的 owner 与模块一致;没有孤儿声明,也没有无主表。

再加一条元规则:快照内容变化时指纹必须重算。指纹只覆盖规范化后的内容,不包含生成时间,否则每次导出都会“漂移”。

快照的 Go 结构

// file: internal/archsnapshot/snapshot.go
// tier: L2
// verify: go test ./internal/archsnapshot/ -count=1
package archsnapshot

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"slices"
	"sort"
)

type Module struct {
	Name       string   `json:"name"`
	Owner      string   `json:"owner"`
	DeployUnit string   `json:"deploy_unit"`
	OwnsData   []string `json:"owns_data,omitempty"`
	DependsOn  []string `json:"depends_on,omitempty"`
}

type DeployUnit struct {
	Name     string   `json:"name"`
	Kind     string   `json:"kind"` // runtime | data
	Modules  []string `json:"modules,omitempty"`
	Replicas int      `json:"replicas"`
}

type DataStore struct {
	Table      string `json:"table"`
	Engine     string `json:"engine"`
	Owner      string `json:"owner"`
	DeployUnit string `json:"deploy_unit"`
}

type Snapshot struct {
	Version     string       `json:"version"`
	Owners      []string     `json:"owners"`
	Modules     []Module     `json:"modules"`
	DeployUnits []DeployUnit `json:"deploy_units"`
	DataStores  []DataStore  `json:"data_stores"`
	Fingerprint string       `json:"fingerprint,omitempty"`
}

type Finding struct {
	Code    string `json:"code"`
	Ref     string `json:"ref"`
	Message string `json:"message"`
}

// Validate 检查模块、部署单元、数据 owner 三者是否闭合。
// 依赖方向与成环由 4.1 的 internal/modules 校验器负责,这里不重复。
func (s Snapshot) Validate() []Finding {
	var out []Finding
	add := func(code, ref, format string, args ...any) {
		out = append(out, Finding{Code: code, Ref: ref, Message: fmt.Sprintf(format, args...)})
	}
	if s.Version == "" {
		add("SNAPSHOT_VERSION", "snapshot", "version is required")
	}
	owners := map[string]bool{}
	for _, o := range s.Owners {
		if o == "" {
			add("OWNER_EMPTY", "owners", "owner must not be empty")
		}
		owners[o] = true
	}
	units := map[string]DeployUnit{}
	listed := map[string]map[string]bool{}
	for _, u := range s.DeployUnits {
		if u.Kind != "runtime" && u.Kind != "data" {
			add("UNIT_KIND", u.Name, "kind %q must be runtime or data", u.Kind)
		}
		units[u.Name] = u
		listed[u.Name] = map[string]bool{}
		for _, name := range u.Modules {
			listed[u.Name][name] = true
		}
	}
	modules := map[string]Module{}
	for _, m := range s.Modules {
		modules[m.Name] = m
	}
	for _, m := range s.Modules {
		if !owners[m.Owner] {
			add("MODULE_OWNER", m.Name, "owner %q not in roster", m.Owner)
		}
		u, ok := units[m.DeployUnit]
		if !ok {
			add("MODULE_UNIT", m.Name, "deploy unit %q does not exist", m.DeployUnit)
		} else if u.Kind != "runtime" {
			add("MODULE_UNIT_KIND", m.Name, "modules can only run in runtime units")
		} else if !listed[u.Name][m.Name] {
			add("MODULE_NOT_LISTED", m.Name, "unit %q does not list this module", u.Name)
		}
	}
	stores := map[string]DataStore{}
	for _, d := range s.DataStores {
		if _, ok := stores[d.Table]; ok {
			add("DATA_TABLE_DUPLICATE", d.Table, "table has more than one owner")
			continue
		}
		stores[d.Table] = d
		m, ok := modules[d.Owner]
		if !ok {
			add("DATA_OWNER", d.Table, "owner module %q does not exist", d.Owner)
			continue
		}
		if !slices.Contains(m.OwnsData, d.Table) {
			add("DATA_CLAIM_MISMATCH", d.Table, "module %q does not claim this table", m.Name)
		}
		if u, ok := units[d.DeployUnit]; !ok || u.Kind != "data" {
			add("DATA_UNIT", d.Table, "store must live in an existing data unit")
		}
	}
	for _, m := range s.Modules {
		for _, table := range m.OwnsData {
			d, ok := stores[table]
			if !ok {
				add("DATA_STORE_MISSING", m.Name, "claimed table %q has no store", table)
			} else if d.Owner != m.Name {
				add("DATA_OWNER_CONFLICT", table, "store owner is %q, module is %q", d.Owner, m.Name)
			}
		}
	}
	if s.Fingerprint != "" && s.Fingerprint != s.ComputeFingerprint() {
		add("FINGERPRINT_DRIFT", "snapshot", "fingerprint does not match content")
	}
	return out
}

// ComputeFingerprint 先排序再哈希,保证同一内容不受声明顺序影响。
func (s Snapshot) ComputeFingerprint() string {
	c := s
	c.Fingerprint = ""
	sort.Slice(c.Modules, func(i, j int) bool { return c.Modules[i].Name < c.Modules[j].Name })
	for i := range c.Modules {
		sort.Strings(c.Modules[i].OwnsData)
		sort.Strings(c.Modules[i].DependsOn)
	}
	sort.Slice(c.DeployUnits, func(i, j int) bool { return c.DeployUnits[i].Name < c.DeployUnits[j].Name })
	for i := range c.DeployUnits {
		sort.Strings(c.DeployUnits[i].Modules)
	}
	sort.Slice(c.DataStores, func(i, j int) bool { return c.DataStores[i].Table < c.DataStores[j].Table })
	raw, _ := json.Marshal(c)
	sum := sha256.Sum256(raw)
	return hex.EncodeToString(sum[:])
}

ComputeFingerprint 先按名字排序模块、单元与表,再排序每个列表内部的元素,最后对 JSON 求 SHA-256。这样 module 声明顺序变化不会造成假漂移,而真正的模块、表或 owner 变化一定会改变指纹。

测试:三处不一致都会被拒收

// file: internal/archsnapshot/snapshot_test.go
// tier: L2
// verify: go test ./internal/archsnapshot/ -run 'TestValidateClean|TestRejectsThreeWayMismatch|TestFingerprint' -v
package archsnapshot

import "testing"

func fixture() Snapshot {
	return Snapshot{
		Version: "arch-lab-v1.0.0",
		Owners:  []string{"team-order", "team-stock", "team-pay", "platform"},
		Modules: []Module{
			{Name: "order", Owner: "team-order", DeployUnit: "orderd", OwnsData: []string{"orders", "order_items"}},
			{Name: "catalog", Owner: "team-order", DeployUnit: "orderd", OwnsData: []string{"skus", "prices"}},
			{Name: "inventory", Owner: "team-stock", DeployUnit: "orderd", OwnsData: []string{"inventory", "reservations"}, DependsOn: []string{"order", "ports"}},
			{Name: "payment", Owner: "team-pay", DeployUnit: "orderd", OwnsData: []string{"payments"}, DependsOn: []string{"order", "ports"}},
			{Name: "ports", Owner: "team-order", DeployUnit: "orderd", DependsOn: []string{"order", "catalog"}},
			{Name: "adapters", Owner: "platform", DeployUnit: "orderd", DependsOn: []string{"order", "catalog", "ports", "platform"}},
			{Name: "platform", Owner: "platform", DeployUnit: "orderd"},
		},
		DeployUnits: []DeployUnit{
			{Name: "orderd", Kind: "runtime", Replicas: 3, Modules: []string{"order", "catalog", "inventory", "payment", "ports", "adapters", "platform"}},
			{Name: "pg-primary", Kind: "data", Replicas: 1},
		},
		DataStores: []DataStore{
			{Table: "orders", Engine: "postgres", Owner: "order", DeployUnit: "pg-primary"},
			{Table: "order_items", Engine: "postgres", Owner: "order", DeployUnit: "pg-primary"},
			{Table: "skus", Engine: "postgres", Owner: "catalog", DeployUnit: "pg-primary"},
			{Table: "prices", Engine: "postgres", Owner: "catalog", DeployUnit: "pg-primary"},
			{Table: "inventory", Engine: "postgres", Owner: "inventory", DeployUnit: "pg-primary"},
			{Table: "reservations", Engine: "postgres", Owner: "inventory", DeployUnit: "pg-primary"},
			{Table: "payments", Engine: "postgres", Owner: "payment", DeployUnit: "pg-primary"},
		},
	}
}

func module(b *Snapshot, name string) *Module {
	for i := range b.Modules {
		if b.Modules[i].Name == name {
			return &b.Modules[i]
		}
	}
	panic("module not found: " + name)
}

func store(b *Snapshot, table string) *DataStore {
	for i := range b.DataStores {
		if b.DataStores[i].Table == table {
			return &b.DataStores[i]
		}
	}
	panic("store not found: " + table)
}

func has(findings []Finding, code string) bool {
	for _, f := range findings {
		if f.Code == code {
			return true
		}
	}
	return false
}

func TestValidateClean(t *testing.T) {
	if got := fixture().Validate(); len(got) != 0 {
		t.Fatalf("clean snapshot returned %v", got)
	}
}

func TestRejectsThreeWayMismatch(t *testing.T) {
	tests := []struct {
		name   string
		mutate func(*Snapshot)
		code   string
	}{
		{"module missing from unit", func(s *Snapshot) {
			s.DeployUnits[0].Modules = []string{"order", "catalog", "inventory", "ports", "adapters", "platform"}
		}, "MODULE_NOT_LISTED"},
		{"unknown data owner", func(s *Snapshot) { store(s, "payments").Owner = "team-ghost" }, "DATA_OWNER"},
		{"two modules claim one table", func(s *Snapshot) {
			module(s, "order").OwnsData = append(module(s, "order").OwnsData, "payments")
		}, "DATA_OWNER_CONFLICT"},
		{"unknown deploy unit", func(s *Snapshot) { module(s, "platform").DeployUnit = "ghost" }, "MODULE_UNIT"},
		{"store in runtime unit", func(s *Snapshot) { store(s, "orders").DeployUnit = "orderd" }, "DATA_UNIT"},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			s := fixture()
			tc.mutate(&s)
			if findings := s.Validate(); !has(findings, tc.code) {
				t.Fatalf("want %s, got %v", tc.code, findings)
			}
		})
	}
}

// 黄金指纹同时检查顺序无关性与基线漂移。
func TestFingerprint(t *testing.T) {
	a, b := fixture(), fixture()
	b.Modules[0], b.Modules[1] = b.Modules[1], b.Modules[0]
	if a.ComputeFingerprint() != b.ComputeFingerprint() {
		t.Fatal("fingerprint changed when module order changed")
	}
	a.Fingerprint = a.ComputeFingerprint()
	const want = "b619ce119b160a8b02f5f637c4a7e2510ecf59ad79ce8aee098d0ad7aaf33078"
	if a.Fingerprint != want {
		t.Fatalf("fingerprint = %s, want %s", a.Fingerprint, want)
	}
	if got := a.Validate(); len(got) != 0 {
		t.Fatalf("sealed snapshot returned %v", got)
	}
}

真实运行结果(macOS arm64,Go 1.26.0):

$ go test ./internal/archsnapshot -count=1
ok  	archlab/internal/archsnapshot	0.545s

$ go test ./internal/archsnapshot -run 'TestValidateClean|TestRejectsThreeWayMismatch|TestFingerprint' -v
--- PASS: TestValidateClean (0.00s)
--- PASS: TestRejectsThreeWayMismatch (0.00s)
    --- PASS: TestRejectsThreeWayMismatch/module_missing_from_unit (0.00s)
    --- PASS: TestRejectsThreeWayMismatch/unknown_data_owner (0.00s)
    --- PASS: TestRejectsThreeWayMismatch/two_modules_claim_one_table (0.00s)
    --- PASS: TestRejectsThreeWayMismatch/unknown_deploy_unit (0.00s)
    --- PASS: TestRejectsThreeWayMismatch/store_in_runtime_unit (0.00s)
--- PASS: TestFingerprint (0.00s)
PASS

黄金指纹是 b619ce119b160a8b02f5f637c4a7e2510ecf59ad79ce8aee098d0ad7aaf33078。任何模块、表或 owner 变化都会让这个测试失败,评审者必须先确认变更,再更新常量,不能顺手把测试改绿。

L3:用 PostgreSQL 18.4 核对数据 owner

以下步骤要求一个可用的 PostgreSQL 18.4 实例,本机没有该实例,未在本机执行:

createdb archlab
psql archlab -c "create table orders (id text primary key); comment on table orders is 'owner=order';"
psql archlab -c "create table payments (id text primary key); comment on table payments is 'owner=payment';"
psql "$ARCHLAB_PG_DSN" -At -F $'\t' -c "
select c.relname, obj_description(c.oid,'pg_class')
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r' order by 1;"

示例只建 orders 与 payments 两张表,预期输出两行 <table>\towner=<module>,与快照中对应条目比对。完整七张表由 6.1 的迁移脚本创建,之后跑同一条查询逐条核对;这里只验证注释约定,不验证业务列。

常见错误与边界

把部署单元当成模块是最常见的错误:orderd 里有七个模块,不代表有七个服务。第二个错误是让 DBA 当数据 owner:DBA 能建表,但不能决定哪张表归哪个业务模块写。第三个错误是手工改快照后不重算指纹,CI 会在下一次运行时以 FINGERPRINT_DRIFT 拒绝。

快照只覆盖卷一的单库单进程形态。它不能证明拆分后仍然成立,也不校验 SQL 里是否真的没有跨模块 JOIN;后者需要 8.3 的适应度函数或数据库权限来兜底。L3 步骤只定了核对口径,没有在本机跑过,不能写成“已验证”。

小结

12.2 把架构图变成了可 diff 的数据:七个模块、一个运行时单元、一个数据单元、七张有明确 owner 的表,外加一个内容指纹。internal/archsnapshot 校验三者闭合,黄金指纹把“基线变了”变成一次需要评审的测试失败。下一节把这些结构映射到可测的质量属性场景,并给出风险台账与关闭条件。

阅读导航:上一节:12.1 需求、约束与非功能目标 · 下一节:12.3 质量属性场景与架构风险清单 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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