边界之争不是画图比赛
arch-lab 的订单域有五个模块:order-api 接单,order-worker 处理订单事件,inventory 管库存,payment 管支付,notify 发通知。团队准备拆服务,白板上出现了两种画法:按技术分层拆成“API 服务”和“Worker 服务”,或者按能力拆成“订单服务、库存服务、支付服务、通知服务”。
两种画法都能画得漂亮,但依赖不一样。order-api 同时调用 order-worker、inventory 和 payment;order-worker 调用 inventory;notify 反向调用 order-worker。按技术分层切,order-api 和 order-worker 被放进两个服务,原本的进程内调用立刻变成跨服务调用;按能力切,order-api 和 order-worker 留在订单边界内,但订单服务仍要跨边界调用库存和支付。
拆分粒度不能只看组织架构图,要看三件事:模块之间谁依赖谁、每张表由谁写、变更是否被迫同步。这三件事分别对应业务能力、数据所有权和变更频率。边界上下文与团队划分的背景可以对照限界上下文与团队边界 ,本节只讨论如何把边界变成可检查的判据。
不稳定度:谁应该依赖谁
Robert Martin 的稳定依赖原则把模块分成两类:被很多人依赖、自己很少依赖别人的模块是“稳定”的;依赖很多、很少被依赖的模块是“不稳定”的。用 Ca 表示流入依赖数,Ce 表示流出依赖数,不稳定度定义为:
I = Ce / (Ca + Ce)
I 越接近 0,模块越稳定;越接近 1,越容易被变更推动。依赖应该从 I 高的一侧指向 I 低的一侧,否则一个易变模块会把稳定核心拖进频繁发布。
对 arch-lab 的五个模块计算,本机读数是:
| 模块 | Ca | Ce | I | 解读 |
|---|---|---|---|---|
order-api | 0 | 3 | 1.000 | 纯调用方,变化由下游接口驱动 |
order-worker | 2 | 1 | 0.333 | 被订单 API 和通知依赖,自身依赖库存 |
inventory | 2 | 0 | 0.000 | 被订单链路依赖的稳定核心 |
payment | 1 | 0 | 0.000 | 被订单 API 依赖的稳定核心 |
notify | 0 | 1 | 1.000 | 纯调用方,适合放在边缘 |
I 不是“好”或“坏”的评分,而是方向提示。order-api 的 I=1 并不说明它该被拆掉,而说明它应该依赖稳定接口,而不是把业务规则塞进下游。inventory 的 I=0 也不说明它必须独立部署;它只说明其他模块依赖它时,应该通过稳定契约,而不是直接读写它的表。孤立模块的 I=0 只是分母为零时的约定,评审时还要同时看 Ca+Ce 是否为 0。
三条粒度判据
| 判据 | 要回答的问题 | 通过条件 | 失败信号 |
|---|---|---|---|
| 业务能力 | 边界两侧是否各自拥有完整能力 | 同一 capability 不被切开 | order-api 和 order-worker 被分到两个服务 |
| 数据所有权 | 每张表是否只有一个写入方 | 边界两侧没有共享写表 | 订单表和库存表都被两侧写入 |
| 变更频率 | 两侧是否可以独立发布 | 耦合发布比例低于阈值 | 每次订单改动都必须同时改库存 |
业务能力判据来自限界上下文:一个能力可以由多个模块实现,但这些模块应该落在同一个边界内。把 order-api 和 order-worker 拆开,订单能力就被切成两半,任何订单规则变更都要跨服务协调。
数据所有权判据更硬:一张表只能有一个写入方。共享读可以通过 API、只读副本或事件投影解决,共享写只能带来双重写入和冲突。数据归属的决策方法可以对照数据所有权与存储决策 。
变更频率判据是动态的:即使图上看不出问题,只要两侧每月有 30% 以上的发布必须同步进行,边界就会在发布节奏上失效。这条判据与 1.1 的评分卡共用同一组发布数据;边界检查器不替代发布记录,只负责在结构上先排除明显错误的切法。
边界评审器
下面这个包把模块图、耦合指标和两向边界评审放在一起。它检查五类问题:分区是否完整且不重叠、同一能力是否被切开、同一张表是否被两侧拥有、跨边界依赖是否双向、跨边界边数是否超过预算。实现只用标准库。
// file: internal/platform/split/graph.go
// tier: L1
// verify: go test ./internal/platform/split/ -count=1
package split
import (
"fmt"
"sort"
)
// Edge is one dependency direction between modules.
type Edge struct{ From, To string }
// Module describes one candidate module. Owns lists tables it writes;
// DependsOn lists modules it calls.
type Module struct {
Name string
Capability string
Owns []string
DependsOn []string
}
// Graph is an immutable module dependency graph.
type Graph struct {
modules map[string]Module
names []string
}
// NewGraph validates names, capabilities and dependency targets.
func NewGraph(modules []Module) (*Graph, error) {
g := &Graph{modules: make(map[string]Module, len(modules))}
for _, m := range modules {
if m.Name == "" || m.Capability == "" {
return nil, fmt.Errorf("split: module name and capability are required")
}
if _, ok := g.modules[m.Name]; ok {
return nil, fmt.Errorf("split: duplicate module %q", m.Name)
}
g.modules[m.Name] = m
g.names = append(g.names, m.Name)
}
for _, m := range modules {
for _, dep := range m.DependsOn {
if dep == m.Name {
return nil, fmt.Errorf("split: module %q depends on itself", m.Name)
}
if _, ok := g.modules[dep]; !ok {
return nil, fmt.Errorf("split: module %q depends on unknown %q", m.Name, dep)
}
}
}
sort.Strings(g.names)
return g, nil
}
func unique(items []string) []string {
seen := make(map[string]bool, len(items))
out := make([]string, 0, len(items))
for _, item := range items {
if !seen[item] {
seen[item] = true
out = append(out, item)
}
}
sort.Strings(out)
return out
}
// Metrics is Martin's coupling pair and instability I = Ce/(Ca+Ce).
type Metrics struct {
Ca, Ce int
Instability float64
}
// Metrics computes afferent and efferent coupling for one module.
func (g *Graph) Metrics(name string) (Metrics, error) {
m, ok := g.modules[name]
if !ok {
return Metrics{}, fmt.Errorf("split: unknown module %q", name)
}
ce := len(unique(m.DependsOn))
ca := 0
for _, other := range g.names {
if other == name {
continue
}
for _, dep := range unique(g.modules[other].DependsOn) {
if dep == name {
ca++
break
}
}
}
instability := 0.0
if ca+ce > 0 {
instability = float64(ce) / float64(ca+ce)
}
return Metrics{Ca: ca, Ce: ce, Instability: instability}, nil
}
// MaxCrossEdges is the budget above which every extra edge needs a contract
// test; it is a review trigger, not a hard architectural law.
const MaxCrossEdges = 3
// Verdict is the boundary decision.
type Verdict string
const (
Accept Verdict = "accept"
Revise Verdict = "revise"
Reject Verdict = "reject"
)
// BoundaryReport explains whether one partition is a service boundary.
type BoundaryReport struct {
CrossEdges []Edge
CrossEdgeShare float64
SharedTables []string
SplitCapabilities []string
Bidirectional bool
Verdict Verdict
}
func bothSides(sides map[string][2]bool) []string {
var out []string
for name, flags := range sides {
if flags[0] && flags[1] {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// EvaluateBoundary checks a complete two-way partition against capability
// cohesion, data ownership and the cross-edge budget.
func (g *Graph) EvaluateBoundary(left, right []string) (BoundaryReport, error) {
side := make(map[string]int, len(g.names))
for _, name := range left {
if _, ok := g.modules[name]; !ok {
return BoundaryReport{}, fmt.Errorf("split: unknown module %q on left", name)
}
if side[name] != 0 {
return BoundaryReport{}, fmt.Errorf("split: module %q appears twice or on both sides", name)
}
side[name] = -1
}
for _, name := range right {
if _, ok := g.modules[name]; !ok {
return BoundaryReport{}, fmt.Errorf("split: unknown module %q on right", name)
}
if side[name] != 0 {
return BoundaryReport{}, fmt.Errorf("split: module %q appears twice or on both sides", name)
}
side[name] = 1
}
if len(side) != len(g.names) {
return BoundaryReport{}, fmt.Errorf("split: partition covers %d of %d modules", len(side), len(g.names))
}
report := BoundaryReport{}
edges := make(map[string]bool)
total := 0
tableSides := make(map[string][2]bool)
capabilitySides := make(map[string][2]bool)
for _, from := range g.names {
index := 0
if side[from] > 0 {
index = 1
}
for _, table := range g.modules[from].Owns {
flags := tableSides[table]
flags[index] = true
tableSides[table] = flags
}
flags := capabilitySides[g.modules[from].Capability]
flags[index] = true
capabilitySides[g.modules[from].Capability] = flags
for _, to := range unique(g.modules[from].DependsOn) {
total++
edges[from+"\x00"+to] = true
if side[from] != side[to] {
report.CrossEdges = append(report.CrossEdges, Edge{From: from, To: to})
}
}
}
sort.Slice(report.CrossEdges, func(i, j int) bool {
if report.CrossEdges[i].From != report.CrossEdges[j].From {
return report.CrossEdges[i].From < report.CrossEdges[j].From
}
return report.CrossEdges[i].To < report.CrossEdges[j].To
})
if total > 0 {
report.CrossEdgeShare = float64(len(report.CrossEdges)) / float64(total)
}
report.SharedTables = bothSides(tableSides)
report.SplitCapabilities = bothSides(capabilitySides)
for _, edge := range report.CrossEdges {
if edges[edge.To+"\x00"+edge.From] {
report.Bidirectional = true
break
}
}
switch {
case len(report.SharedTables) > 0 || report.Bidirectional:
report.Verdict = Reject
case len(report.SplitCapabilities) > 0 || len(report.CrossEdges) > MaxCrossEdges:
report.Verdict = Revise
default:
report.Verdict = Accept
}
return report, nil
}
正文只列核心的边界用例;不稳定度读数由同包的 TestInstability 产生,共享表、能力切分、双向依赖和构造校验用例也放在完整测试文件中。
// file: internal/platform/split/graph_test.go
// tier: L1
// verify: go test ./internal/platform/split/ -count=1
package split
import "testing"
func archLabGraph(t *testing.T) *Graph {
t.Helper()
g, err := NewGraph([]Module{
{Name: "order-api", Capability: "order", Owns: []string{"orders"}, DependsOn: []string{"order-worker", "inventory", "payment"}},
{Name: "order-worker", Capability: "order", Owns: []string{"order_events"}, DependsOn: []string{"inventory"}},
{Name: "inventory", Capability: "inventory", Owns: []string{"stock"}},
{Name: "payment", Capability: "payment", Owns: []string{"payments"}},
{Name: "notify", Capability: "notification", Owns: []string{"notifications"}, DependsOn: []string{"order-worker"}},
})
if err != nil {
t.Fatalf("NewGraph: %v", err)
}
return g
}
func TestEvaluateBoundary(t *testing.T) {
g := archLabGraph(t)
wide, err := g.EvaluateBoundary(
[]string{"order-api", "order-worker"},
[]string{"inventory", "payment", "notify"},
)
if err != nil {
t.Fatalf("EvaluateBoundary: %v", err)
}
t.Logf("wide: verdict=%s cross=%d share=%.2f", wide.Verdict, len(wide.CrossEdges), wide.CrossEdgeShare)
if wide.Verdict != Revise || len(wide.CrossEdges) != 4 {
t.Fatalf("wide: verdict=%s cross=%d, want revise/4", wide.Verdict, len(wide.CrossEdges))
}
narrow, err := g.EvaluateBoundary(
[]string{"order-api", "order-worker", "payment"},
[]string{"inventory", "notify"},
)
if err != nil {
t.Fatalf("EvaluateBoundary: %v", err)
}
t.Logf("narrow: verdict=%s cross=%d share=%.2f", narrow.Verdict, len(narrow.CrossEdges), narrow.CrossEdgeShare)
if narrow.Verdict != Accept || len(narrow.CrossEdges) != 3 {
t.Fatalf("narrow: verdict=%s cross=%d, want accept/3", narrow.Verdict, len(narrow.CrossEdges))
}
}
本机实测
命令与环境同 1.1:module archlab,Go 1.26.0,macOS darwin/arm64。完整包中与本篇相关的四个测试本体如下:
$ cd .local/checks/be-arch-gov-ch01
$ gofmt -l .
$ go vet ./...
$ go test ./internal/platform/split/ -run '^(TestInstability|TestEvaluateBoundary|TestBoundaryRejections|TestNewGraphValidation)$' -count=1 -v
=== RUN TestInstability
graph_test.go:42: module=order-api Ca=0 Ce=3 I=1.000
graph_test.go:42: module=order-worker Ca=2 Ce=1 I=0.333
graph_test.go:42: module=inventory Ca=2 Ce=0 I=0.000
graph_test.go:42: module=payment Ca=1 Ce=0 I=0.000
graph_test.go:42: module=notify Ca=0 Ce=1 I=1.000
--- PASS: TestInstability (0.00s)
=== RUN TestEvaluateBoundary
graph_test.go:58: wide: verdict=revise cross=4 share=0.80
graph_test.go:69: narrow: verdict=accept cross=3 share=0.60
--- PASS: TestEvaluateBoundary (0.00s)
=== RUN TestBoundaryRejections
=== RUN TestBoundaryRejections/shared_table
graph_test.go:105: verdict=reject tables=[orders] capabilities=[] bidir=false
=== RUN TestBoundaryRejections/split_capability
graph_test.go:105: verdict=revise tables=[] capabilities=[order] bidir=false
=== RUN TestBoundaryRejections/bidirectional
graph_test.go:105: verdict=reject tables=[] capabilities=[] bidir=true
--- PASS: TestBoundaryRejections (0.00s)
--- PASS: TestBoundaryRejections/shared_table (0.00s)
--- PASS: TestBoundaryRejections/split_capability (0.00s)
--- PASS: TestBoundaryRejections/bidirectional (0.00s)
=== RUN TestNewGraphValidation
=== RUN TestNewGraphValidation/duplicate
=== RUN TestNewGraphValidation/unknown_dep
=== RUN TestNewGraphValidation/self_dep
=== RUN TestNewGraphValidation/empty_capability
--- PASS: TestNewGraphValidation (0.00s)
--- PASS: TestNewGraphValidation/duplicate (0.00s)
--- PASS: TestNewGraphValidation/unknown_dep (0.00s)
--- PASS: TestNewGraphValidation/self_dep (0.00s)
--- PASS: TestNewGraphValidation/empty_capability (0.00s)
PASS
ok archlab/internal/platform/split 0.243s
读法分三层。耦合层,inventory 和 payment 的 I=0,说明它们是被依赖的稳定核心;order-api 和 notify 的 I=1,说明它们处在调用边缘。边界层,宽切法留下 4 条跨边界边、占全部 5 条边的 80%,被判 revise;把 payment 移到订单侧后,跨边界边降到 3 条、占比 60%,被判 accept。拒绝层,共享表直接 reject,能力被切开是 revise,双向依赖是 reject,三种失败信号都能被识别。
这些读数只证明检查器在该组手工构造的模块图上按规则工作。真实系统的依赖边来自调用链和数据库访问日志,往往比白板图多得多;上线前应把实际依赖导入同一结构重跑,而不是把本节的 5 个模块当成真实规模。跨边界边预算 3 也只是本例的示例值,边越多,契约测试与故障处理的成本越高,阈值应随团队能力调整。
常见错误与边界
- 把不稳定度当成服务好坏。
I=1只说明模块依赖别人多,不说明它该被合并或删除;方向比数值更重要。 - 用模块图代替数据所有权。图上没有共享表,代码仍可能让两个服务写同一张表;必须把
Owns从建表脚本和写路径里核实。 - 只看静态依赖,不看变更频率。结构上干净的边界,如果两侧发布仍要同步,实际仍是单体;变更频率要用发布记录验证。
- 边界检查器只做结构与预算判断,不验证调用延迟、事务边界和故障传播;这些必须由契约测试与演练补齐,相关门禁思路可对照架构适应度函数 。
小结
拆分粒度不是“切得越细越好”,而是让边界同时满足三条判据:业务能力完整、数据只有一个写入方、两侧可以独立发布。不稳定度提供了一个结构化的方向检查:依赖应从易变边缘指向稳定核心,而不是反过来。
本节实现把图构造、Ca/Ce 计算、两向分区校验、共享表与能力切分检测、跨边界边预算放在同一个包里。本机测试显示宽切法 4 条跨边界边被判 revise,窄切法 3 条被判 accept;这些数字来自手工构造的 arch-lab 模块图,真实依赖必须重新采集。通过检查器只说明结构及格,是否最终拆分仍要回到变更频率和 1.1 的就绪门。
阅读导航:上一节:1.1 微服务拆分不是默认答案:三个触发条件 · 下一节:1.3 订单域的一次拆分演练:边界、共享数据与回退 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。