先定读者,再决定画到哪一层
同一个系统需要四张图,不是因为四张图更完整,而是因为四类读者问的问题不同。给业务方看部署拓扑,他会追问“这和我的下单失败有什么关系”;给新同事看类图,他又找不到支付网关在哪。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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。