《后端架构设计·架构基础与边界》2.1 arch-lab 的四级视图(C4)

直接给出 arch-lab 的四级 C4 视图——系统上下文、容器、组件与代码,逐级说明读者、要回答的问题和刻意不画的内容;再把容器归属与组件依赖编码成 Go 数据结构,用表驱动校验器检查空归属、未知容器、未知依赖与孤儿组件,附 darwin/arm64 上的真实测试输出。

先定读者,再决定画到哪一层

同一个系统需要四张图,不是因为四张图更完整,而是因为四类读者问的问题不同。给业务方看部署拓扑,他会追问“这和我的下单失败有什么关系”;给新同事看类图,他又找不到支付网关在哪。arch-lab 的规则是:每张图只回答一个问题,超出这个问题的元素一律不画。

下面四节不是 C4 方法论综述,而是 arch-lab 卷一方案的四张具体视图。每节都写清读者、要回答的问题和刻意省略的内容。最后一节把视图编码成可校验的数据,防止图和代码各说各话。

L1 系统上下文:谁在用,钱从哪来

系统上下文只画三类东西:人、arch-lab 订单平台、与它交互的外部系统。它回答“系统的边界在哪、谁对它负责、它依赖谁完成交易”。

┌──────────┐   浏览 / 下单 / 取消   ┌────────────────────────┐
│  顾客     │──────────────────────▶│  arch-lab 订单平台      │
└──────────┘                       │  (被描述系统)          │
┌──────────┐   查单 / 处理退款      │                        │
│ 运营人员  │──────────────────────▶│                        │
└──────────┘                       └───────────┬────────────┘
                                               │ 支付 / 退款
                                               ▼
                                    ┌────────────────────┐
                                    │  外部支付网关        │
                                    └────────────────────┘
  • 读者:业务方、新加入的工程师、需要判断故障影响面的值班人员。
  • 回答:订单平台负责什么,谁触发下单,支付失败时哪个外部系统在链路上。
  • 不画:数据库、包、类、线程池。它们属于下一层,画进来只会让业务方误以为“数据库挂了等于整个平台挂了”。

L1 有一个容易漏掉的边界:库存不是外部系统。卷一里库存预留是 arch-lab 自己的模块,画成外部服务会让人误判故障归属。只有当库存真的拆成独立部署单元时,它才应该出现在容器层。

L2 容器:一个进程、一个库、一个外部网关

容器层把系统展开成可独立运行或部署的单元。arch-lab 卷一只有一个部署单元 orderd,这不是疏忽,而是 ADR-001 的直接结果。

┌───────────┐   HTTPS   ┌──────────────────────────────┐
│ Web / 客户端│──────────▶│ orderd(Go 1.26,单进程)      │
└───────────┘           │ HTTP API + 领域模块 + 适配器    │
                        └───────┬──────────────┬────────┘
                                │ SQL          │ HTTPS
                                ▼              ▼
                     ┌──────────────────┐  ┌──────────────────┐
                     │ PostgreSQL 18.4  │  │ 外部支付网关       │
                     │ 单写库            │  └──────────────────┘
                     └──────────────────┘
  • 读者:开发、SRE、负责发布的人。
  • 回答:一次请求跨几个进程,状态落在哪里,哪个单元需要备份和监控。
  • 不画:模块内部的 handler、聚合和接口。容器层只关心“进程边界”和“数据边界”。

这张图能直接支撑三个运维判断:orderd 可以水平复制,但 PostgreSQL 是单写库;支付网关故障不会阻止已创建订单的查询;备份对象只有数据库,进程本身无状态。它也暴露了 ADR-003 的代价:写路径的可用性上限由单写库决定。

L3 组件:orderd 内部谁依赖谁

组件层展开 orderd 容器。这里的“组件”不是微服务,而是包级别的职责单元;箭头表示编译期依赖,不是运行时调用顺序。

                    ┌──────────────── orderd ────────────────┐
  HTTP 请求 ───────▶│ httpserver ──▶ order / inventory / payment│
                    │      │              ▲          ▲         │
                    │      ▼              │          │         │
                    │ idempotency      ports ────────┘         │
                    │                     ▲                     │
                    │      adapter-memory / adapter-postgres    │
                    │ config(叶子,无领域依赖)                  │
                    └───────────────────────────────────────────┘
  • 读者:模块负责人、准备改代码的人、做代码评审的人。
  • 回答:新增一个功能应该落在哪个包,跨模块调用必须经过谁,哪些包不能反向依赖。
  • 不画:类的方法签名、SQL 语句、表结构。这些是 L4 或迁移脚本的内容,塞进组件图会迅速过时。

组件图与 1.3 的 deplint 规则必须一致:order、catalog、inventory、payment 只依赖标准库;ports 可以引用领域类型;adapters 实现 ports;platform 是叶子。图里少画一条越界箭头,检查器仍然会把它抓出来。

L4 代码:包结构与类型落点

代码层只展开一个组件,通常选正在改的那个。对 arch-lab 来说,L4 不是全量类图,而是“包 → 关键类型 → 端口”的映射。

internal/order/        Order, OrderItem, Status, Error, TransitionTo
internal/catalog/      SKU, Price
internal/inventory/    InventoryReservation, Reserve/Release
internal/payment/      Payment, Capture/Refund
internal/ports/        OrderRepository, InventoryPort, PaymentPort, EventPublisher
internal/adapters/     memory 与 postgres 对上述端口的实现
internal/platform/     config, httpserver, idempotency
cmd/orderd/            main:装配适配器与 HTTP 路由
  • 读者:正在实现某个用例的开发者、需要写测试的人。
  • 回答:某个概念的类型定义在哪,哪个接口允许替换实现,测试应该 mock 哪一层。
  • 不画:所有字段和所有方法。L4 图一旦求全,就会退化成需要持续维护的第二份代码。

L4 的验收标准不是“图画得全”,而是“新人能根据图找到第一个要改的文件”。如果一张 L4 图不能指向具体包,它就没有完成自己的任务。

把四级视图编码成可校验的数据

图的问题是会漂移。今天组件图画了十个组件,明天有人加了 internal/fulfillment 却忘了更新图,评审时也没人发现。解决办法不是“记得更新”,而是让视图和检查器共用一份数据。

下面的模型只编码两件事:组件属于哪个容器,组件依赖哪些组件。它不试图表达时序、部署参数或数据库表,那些各有自己的事实来源。

// file: internal/archmodel/model.go
// tier: L1
// verify: go test ./internal/archmodel/
package archmodel

type Kind string

const (
	KindApplication Kind = "application"
	KindDatastore   Kind = "datastore"
	KindExternal    Kind = "external"
)

// Container 是 C4 的容器:一个可独立运行或部署的单元。
type Container struct {
	ID           string
	Name         string
	Kind         Kind
	ComponentIDs []string
}

// Component 的 DependsOn 只记录同容器内的组件依赖。
type Component struct {
	ID          string
	Name        string
	Package     string
	ContainerID string
	DependsOn   []string
}

type Model struct {
	Containers []Container
	Components []Component
}

func ArchLab() Model {
	return Model{
		Containers: []Container{
			{ID: "orderd", Name: "orderd 单进程", Kind: KindApplication, ComponentIDs: []string{
				"config", "httpserver", "idempotency", "order", "catalog",
				"inventory", "payment", "ports", "adapter-memory", "adapter-postgres",
			}},
			{ID: "postgres", Name: "PostgreSQL 18.4 单写库", Kind: KindDatastore},
			{ID: "payment-gateway", Name: "外部支付网关", Kind: KindExternal},
		},
		Components: []Component{
			{ID: "config", Name: "配置加载", Package: "internal/platform/config", ContainerID: "orderd"},
			{ID: "httpserver", Name: "HTTP 入口", Package: "internal/platform/httpserver", ContainerID: "orderd",
				DependsOn: []string{"order", "inventory", "payment", "idempotency"}},
			{ID: "idempotency", Name: "幂等存储", Package: "internal/platform/idempotency", ContainerID: "orderd"},
			{ID: "order", Name: "订单聚合", Package: "internal/order", ContainerID: "orderd"},
			{ID: "catalog", Name: "SKU 与价格", Package: "internal/catalog", ContainerID: "orderd"},
			{ID: "inventory", Name: "库存预留", Package: "internal/inventory", ContainerID: "orderd"},
			{ID: "payment", Name: "支付状态", Package: "internal/payment", ContainerID: "orderd"},
			{ID: "ports", Name: "端口接口", Package: "internal/ports", ContainerID: "orderd",
				DependsOn: []string{"order", "catalog", "inventory", "payment"}},
			{ID: "adapter-memory", Name: "内存适配器", Package: "internal/adapters/memory", ContainerID: "orderd",
				DependsOn: []string{"ports", "order", "catalog", "inventory", "payment"}},
			{ID: "adapter-postgres", Name: "PostgreSQL 适配器", Package: "internal/adapters/postgres", ContainerID: "orderd",
				DependsOn: []string{"ports", "order", "catalog", "inventory", "payment"}},
		},
	}
}

校验器要回答四个问题:ID 是否唯一、每个组件是否有且只有一个归属容器、每条依赖是否指向已声明组件、有没有组件只存在于全局表却没有任何容器认领。最后一个就是“孤儿组件”。

// file: internal/archmodel/validate.go
// tier: L1
// verify: go test ./internal/archmodel/ -run TestValidateTable -v
package archmodel

import (
	"fmt"
	"sort"
)

type Issue struct {
	Code   string
	Ref    string
	Detail string
}

func (i Issue) String() string {
	return fmt.Sprintf("%s %s: %s", i.Code, i.Ref, i.Detail)
}

const (
	CodeDuplicateID        = "DUPLICATE_ID"
	CodeMissingContainer   = "MISSING_CONTAINER"
	CodeUnknownContainer   = "UNKNOWN_CONTAINER"
	CodeUnknownComponent   = "UNKNOWN_COMPONENT"
	CodeUnknownDependency  = "UNKNOWN_DEPENDENCY"
	CodeOrphanComponent    = "ORPHAN_COMPONENT"
	CodeMembershipMismatch = "MEMBERSHIP_MISMATCH"
	CodeSelfDependency     = "SELF_DEPENDENCY"
)

// Validate 校验 ID 唯一、唯一归属、依赖存在、无孤儿组件。
func (m Model) Validate() []Issue {
	var issues []Issue

	containers := make(map[string]bool, len(m.Containers))
	for _, c := range m.Containers {
		if containers[c.ID] {
			issues = append(issues, Issue{CodeDuplicateID, c.ID, "container declared twice"})
		}
		containers[c.ID] = true
	}

	components := make(map[string]Component, len(m.Components))
	for _, comp := range m.Components {
		if _, ok := components[comp.ID]; ok {
			issues = append(issues, Issue{CodeDuplicateID, comp.ID, "component declared twice"})
		}
		components[comp.ID] = comp
	}

	owner := make(map[string]string, len(m.Components))
	for _, c := range m.Containers {
		for _, id := range c.ComponentIDs {
			if prev, ok := owner[id]; ok && prev != c.ID {
				issues = append(issues, Issue{CodeMembershipMismatch, id,
					fmt.Sprintf("listed by %s and %s", prev, c.ID)})
				continue
			}
			owner[id] = c.ID
			if _, ok := components[id]; !ok {
				issues = append(issues, Issue{CodeUnknownComponent, id, "listed by " + c.ID + " but not declared"})
			}
		}
	}

	for _, comp := range m.Components {
		switch {
		case comp.ContainerID == "":
			issues = append(issues, Issue{CodeMissingContainer, comp.ID, "ContainerID is empty"})
		case !containers[comp.ContainerID]:
			issues = append(issues, Issue{CodeUnknownContainer, comp.ID, "container " + comp.ContainerID + " not found"})
		default:
			got, ok := owner[comp.ID]
			if !ok {
				issues = append(issues, Issue{CodeOrphanComponent, comp.ID, "not listed by any container"})
			} else if got != comp.ContainerID {
				issues = append(issues, Issue{CodeMembershipMismatch, comp.ID,
					fmt.Sprintf("ContainerID=%s but listed by %s", comp.ContainerID, got)})
			}
		}
		for _, dep := range comp.DependsOn {
			if dep == comp.ID {
				issues = append(issues, Issue{CodeSelfDependency, comp.ID, "depends on itself"})
			}
			if _, ok := components[dep]; !ok {
				issues = append(issues, Issue{CodeUnknownDependency, comp.ID, "missing target " + dep})
			}
		}
	}

	sort.SliceStable(issues, func(i, j int) bool {
		if issues[i].Code != issues[j].Code {
			return issues[i].Code < issues[j].Code
		}
		return issues[i].Ref < issues[j].Ref
	})
	return issues
}

测试采用“复制真实模型再破坏一处”的方式。这样每个用例只验证一条规则,也不会因为新增组件而全部重写。

// file: internal/archmodel/validate_test.go
// tier: L1
// verify: go test ./internal/archmodel/ -count=1 -v
package archmodel

import "testing"

func issueCodes(issues []Issue) map[string]bool {
	codes := make(map[string]bool, len(issues))
	for _, issue := range issues {
		codes[issue.Code] = true
	}
	return codes
}

func TestValidateTable(t *testing.T) {
	if issues := ArchLab().Validate(); len(issues) != 0 {
		t.Fatalf("ArchLab() has %d issue(s): %v", len(issues), issues)
	}
	tests := []struct {
		name   string
		mutate func(*Model)
		want   string
	}{
		{"empty container id", func(m *Model) { m.Components[3].ContainerID = "" }, CodeMissingContainer},
		{"unknown container", func(m *Model) { m.Components[3].ContainerID = "ghost" }, CodeUnknownContainer},
		{"unknown dependency", func(m *Model) { m.Components[3].DependsOn = []string{"ghost"} }, CodeUnknownDependency},
		{"orphan component", func(m *Model) {
			m.Containers[0].ComponentIDs = m.Containers[0].ComponentIDs[1:]
		}, CodeOrphanComponent},
		{"membership mismatch", func(m *Model) { m.Components[3].ContainerID = "postgres" }, CodeMembershipMismatch},
		{"duplicate component", func(m *Model) { m.Components = append(m.Components, m.Components[0]) }, CodeDuplicateID},
		{"self dependency", func(m *Model) { m.Components[3].DependsOn = []string{"order"} }, CodeSelfDependency},
		{"component listed but not declared", func(m *Model) {
			m.Components = m.Components[:len(m.Components)-1]
		}, CodeUnknownComponent},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			model := ArchLab()
			tt.mutate(&model)
			if got := issueCodes(model.Validate()); !got[tt.want] {
				t.Fatalf("codes = %v, want %s", got, tt.want)
			}
		})
	}
}

真实运行结果

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

$ go test ./internal/archmodel/ -count=1 -v
=== RUN   TestValidateTable
=== RUN   TestValidateTable/empty_container_id
=== RUN   TestValidateTable/unknown_container
=== RUN   TestValidateTable/unknown_dependency
=== RUN   TestValidateTable/orphan_component
=== RUN   TestValidateTable/membership_mismatch
=== RUN   TestValidateTable/duplicate_component
=== RUN   TestValidateTable/self_dependency
=== RUN   TestValidateTable/component_listed_but_not_declared
--- PASS: TestValidateTable (0.00s)
    --- PASS: TestValidateTable/empty_container_id (0.00s)
    --- PASS: TestValidateTable/unknown_container (0.00s)
    --- PASS: TestValidateTable/unknown_dependency (0.00s)
    --- PASS: TestValidateTable/orphan_component (0.00s)
    --- PASS: TestValidateTable/membership_mismatch (0.00s)
    --- PASS: TestValidateTable/duplicate_component (0.00s)
    --- PASS: TestValidateTable/self_dependency (0.00s)
    --- PASS: TestValidateTable/component_listed_but_not_declared (0.00s)
PASS
ok  	archlab/internal/archmodel	0.238s

这个检查器的价值不在“证明架构正确”,而在图与数据不一致时立即失败。它也有明确边界:只校验组件归属和依赖目标,不校验依赖方向(那是 deplint 的职责)、不校验容器之间是否真的通过网络通信、也抓不到运行时反射或注册表产生的依赖。

与站内 C4 文章的分工

站内的 C4 模型与架构文档化 已经讲清四层抽象的定义、Structurizr/PlantUML 的图即代码实践,以及如何让文档不过时。本节不重复这些定义,只交付 arch-lab 这一套具体视图,并往前走一步:把视图变成能返回非零退出码的数据校验。C4 回答“画什么”,本节回答“画完之后怎么防止它骗人”。

常见错误与边界

第一,把系统上下文图画成部署图。一旦出现数据库和队列,业务方就会开始讨论连接数,而不再讨论业务边界。

第二,容器层把“模块”画成容器。order、inventory、payment 在卷一都跑在同一个进程里,把它们画成三个可独立部署的单元,会掩盖 ADR-001 的真实约束。

第三,组件图只画箭头不画禁止边。评审时大家记得“HTTP 调订单”,却没人记得“订单不能调适配器”;后者才是边界会被侵蚀的地方。

第四,L4 追求全量类图。类图越全,维护成本越高,过时速度越快;按用例展开一个组件就够了。

第五,视图和检查器各维护一份组件清单。图里十个组件、代码里十一个,检查器还显示绿色,这比没有检查器更危险。

第六,把 archcheck 当成架构正确性证明。它能证明“登记关系自洽”,不能证明“这个组件划分合理”;后者仍然需要评审和 ADR。

小结

四级视图各自只回答一个问题:L1 回答边界与外部依赖,L2 回答进程与数据落点,L3 回答模块职责与依赖方向,L4 回答类型与端口的落点。arch-lab 的四张图与 ADR-001 的模块化单体完全一致:一个 orderd 进程、一个 PostgreSQL 单写库、一个外部支付网关。把容器归属和组件依赖编码成 internal/archmodel 后,archcheck 能在孤儿组件、未知依赖和归属冲突出现时返回非零退出码。下一节把这套方案背后的五条关键决策写成 ADR-003 至 ADR-007。

阅读导航:上一节:1.3 架构风格谱系与 arch-lab 选型矩阵 · 下一节:2.2 订单系统的五个 ADR 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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