先把 arch-lab 的功能清单摊开
arch-lab 到第 3 章已经有十四个用例:建单、改价、预留库存、释放库存、支付、退款、发货、签收、取消、超时关单、查单、列单、通知、登录。把它们直接映射成十四个包,看起来整齐,实际会把“订单状态”和“库存数量”绑在同一条变更链上:改一次促销规则,库存和通知都要重新发布。
子域划分要回答的不是“有哪些功能”,而是三个更硬的问题:这块能力变化有多快、它是否构成业务差异、谁对它负责。三个问题的答案不一致时,边界就该重新画。
子域判据:变化频率、差异化、团队归属
下面的频率是团队按过去两个季度需求单来源做的粗估,不是监控指标;它只用于排序,不用于承诺。差异化按“换掉它是否影响用户选择”判断,团队归属按“谁值班、谁有权改规则”判断。
| 子域 | 包含能力 | 变化频率(估计) | 业务差异化 | 负责团队 | 判定 |
|---|---|---|---|---|---|
| 订单 | 订单生命周期、状态机、取消与退款编排 | 高,几乎每个活动都改 | 直接决定交易是否可信 | team-order | 核心域 |
| 定价 | SKU 价格、优惠分摊、币种与舍入 | 高,促销按周变 | 价格策略是竞争手段 | team-catalog | 核心域 |
| 库存 | 可售量、预留、释放、超卖控制 | 中,规则稳定但容量压力大 | 标准能力,不构成差异 | team-inventory | 支撑域 |
| 履约 | 发货、拆包、签收、逆向物流 | 中,随仓配模式变 | 可采购或外包 | team-fulfillment | 支撑域 |
| 身份 | 账号、凭据、会话、租户 | 低,标准协议 | 不应自研 | team-identity | 通用域 |
| 通知 | 邮件、短信、站内信、模板 | 低,渠道可替换 | 不应自研 | team-notify | 通用域 |
| 支付网关适配 | 渠道签名、回调验签、对账文件 | 低,按渠道文档变 | 渠道适配不是差异化 | team-payments | 通用域 |
这张表里有两个容易误判的地方。第一,支付被拆成两半:Payment 聚合(支付单、退款单、幂等键)属于支撑域,因为它的状态机与订单强相关;支付网关适配属于通用域,因为它只做协议转换,换渠道不应改领域模型。第二,定价放在核心域而不是 catalog 的附属功能,因为价格快照、优惠分摊和舍入规则会直接影响订单金额,变更频率也最高。
库存和履约被归为支撑域,不等于它们不重要。支撑域的意思是“必须做好,但不靠它赢”,所以可以用更标准的模型、更保守的容量策略,而不是把每个活动规则都塞进去。
限界上下文映射表
子域是问题空间的切分,限界上下文是解决方案空间的切分。一个子域可以对应多个上下文,一个上下文也不应该跨两个子域。arch-lab 的映射如下:
| 上下文 | 职责 | 拥有数据 | 上游/下游 | 集成模式 | Owner | 包 |
|---|---|---|---|---|---|---|
| order | 订单聚合、状态机、订单事件 | orders、order_items、order_status_history | 下游:inventory、payment、fulfillment、notification;上游:catalog、identity | 客户-供应商;对 identity 用防腐层 | team-order | internal/order |
| catalog | SKU、价格簿、价格快照 | sku、price_book、price_snapshot | 上游:order | 客户-供应商 | team-catalog | internal/catalog |
| inventory | 可售量、按 SKU/仓预留与释放 | stock、inventory_reservations | 下游:order 的预留命令;上游:order 的订单事实 | 客户-供应商 | team-inventory | internal/inventory |
| fulfillment | 发货单、拆包、签收 | shipments、shipment_items | 下游:order | 客户-供应商 | team-fulfillment | internal/fulfillment |
| payment | 支付单、退款单、渠道回调 | payments、provider_refs、refunds | 下游:order;上游:支付渠道 | 客户-供应商;渠道侧防腐层 | team-payments | internal/payment |
| identity | 账号、凭据、租户 | accounts、credentials、tenants | 上游:order | 防腐层 | team-identity | internal/identity |
| notification | 模板、渠道投递、失败重试 | templates、delivery_log | 下游:order | 客户-供应商(事件订阅) | team-notify | internal/notification |
映射表里有三条约束值得单独说明。
第一,order 不拥有库存数据。它只保存 InventoryReservation 的引用和状态摘要,不保存 stock;库存是否可预留由 inventory 在自己的事务里判断,order 只负责把“预留失败”翻译成 OUT_OF_STOCK。第二,catalog 是 order 的上游,但 order 保存价格快照。下单那一刻把 UnitPriceCents 写进 OrderItem,之后价格簿怎么改都不回写历史订单。第三,identity 到 order 用防腐层。订单只需要 CustomerID 和一个“该客户是否可下单”的判断,不需要账号、凭据、租户这些模型;没有防腐层,identity 改一次登录方式,order 的测试就会跟着坏。
与模块化单体的包边界对齐
ADR-001 选择模块化单体,所以上下文边界必须落到 Go 包边界,否则映射表只是一张墙上的图。对齐关系如下:
| 上下文 | 包 | 允许依赖 | 禁止依赖 |
|---|---|---|---|
| order | internal/order | 标准库、internal/shared/money | adapters、platform、其他上下文 |
| catalog | internal/catalog | 标准库、internal/shared/money | adapters、platform、order |
| inventory | internal/inventory | 标准库 | order、payment、adapters |
| fulfillment | internal/fulfillment | 标准库、internal/shared/money | order、inventory、adapters |
| payment | internal/payment | 标准库、internal/shared/money | order、adapters(渠道适配除外) |
| identity | internal/identity | 标准库 | 其他上下文、adapters |
| notification | internal/notification | 标准库 | 其他上下文、adapters |
internal/shared/money 是本章新增的共享内核:只有 order、catalog、payment、fulfillment 四个声明过的上下文可以 import 它。共享内核越小越好,只放 Money、Currency 和舍入函数;一旦有人往里面塞订单状态或库存规则,它就会变成第二个“大泥球”。
1.3 的目录清单没有列出 internal/fulfillment,因为那时履约还只是订单里的一个状态。本章把它注册为独立包,理由不是“功能多”,而是它有独立的发货单标识、独立的值班团队和独立的失败语义。这是一次有意的目录变更,应该在 ADR 里留痕,而不是悄悄加一个目录。
把映射表变成可执行校验
目录和表格都会被违反,所以把上下文、依赖、共享内核编码成结构,并提供 Validate。下面只保留最关键的规则:上下文唯一、owner 非空、包唯一、依赖端点存在、依赖无环、共享内核只能被声明过的成员引用。
// file: internal/contextmap/map.go
// tier: L1
// verify: go test ./internal/contextmap/
package contextmap
import (
"fmt"
"strings"
)
type Kind string
const (
KindCore Kind = "CORE"
KindSupporting Kind = "SUPPORTING"
KindGeneric Kind = "GENERIC"
)
type Pattern string
const (
PatternPartnership Pattern = "PARTNERSHIP"
PatternCustomerSupplier Pattern = "CUSTOMER_SUPPLIER"
PatternACL Pattern = "ANTICORRUPTION_LAYER"
PatternSharedKernel Pattern = "SHARED_KERNEL"
)
type Context struct {
Name string
Kind Kind
Owner string
Package string
Owns []string
}
type Dependency struct {
Upstream string
Downstream string
Pattern Pattern
Reason string
}
type SharedKernel struct {
Name string
Owner string
Members []string
}
type Map struct {
Contexts []Context
Deps []Dependency
Kernels []SharedKernel
}
// Validate 返回全部违规,而不是遇到第一条就退出:一次修完比来回跑 CI 便宜。
func (m Map) Validate() []string {
var out []string
byName := make(map[string]Context, len(m.Contexts))
byPackage := make(map[string]string, len(m.Contexts))
for _, c := range m.Contexts {
if _, dup := byName[c.Name]; dup {
out = append(out, "duplicate context "+c.Name)
}
byName[c.Name] = c
if c.Owner == "" {
out = append(out, "missing owner "+c.Name)
}
if prev, used := byPackage[c.Package]; used {
out = append(out, fmt.Sprintf("package %s used by %s and %s", c.Package, prev, c.Name))
}
byPackage[c.Package] = c.Name
}
for _, d := range m.Deps {
if _, ok := byName[d.Upstream]; !ok {
out = append(out, "unknown upstream "+d.Upstream)
}
if _, ok := byName[d.Downstream]; !ok {
out = append(out, "unknown downstream "+d.Downstream)
}
if d.Upstream == d.Downstream {
out = append(out, "self dependency "+d.Upstream)
}
}
for _, k := range m.Kernels {
if len(k.Members) < 2 {
out = append(out, "kernel too small "+k.Name)
}
for _, member := range k.Members {
if _, ok := byName[member]; !ok {
out = append(out, "unknown kernel member "+k.Name+"."+member)
}
}
}
for _, d := range m.Deps {
if d.Pattern == PatternSharedKernel && !sharesKernel(m.Kernels, d.Upstream, d.Downstream) {
out = append(out, "undeclared shared kernel "+d.Upstream+" -> "+d.Downstream)
}
}
return append(out, m.cycles()...)
}
func sharesKernel(kernels []SharedKernel, a, b string) bool {
for _, k := range kernels {
if contains(k.Members, a) && contains(k.Members, b) {
return true
}
}
return false
}
func contains(values []string, want string) bool {
for _, v := range values {
if v == want {
return true
}
}
return false
}
func (m Map) cycles() []string {
adj := make(map[string][]string, len(m.Contexts))
for _, d := range m.Deps {
adj[d.Downstream] = append(adj[d.Downstream], d.Upstream)
}
const white, gray, black = 0, 1, 2
color := make(map[string]int, len(m.Contexts))
var stack []string
var out []string
var visit func(string)
visit = func(node string) {
color[node] = gray
stack = append(stack, node)
for _, next := range adj[node] {
switch color[next] {
case white:
visit(next)
case gray:
begin := 0
for i, n := range stack {
if n == next {
begin = i
break
}
}
path := append(append([]string(nil), stack[begin:]...), next)
out = append(out, "dependency cycle "+strings.Join(path, " -> "))
}
}
stack = stack[:len(stack)-1]
color[node] = black
}
for _, c := range m.Contexts {
if color[c.Name] == white {
visit(c.Name)
}
}
return out
}
校验器只能证明“声明之间不矛盾”,不能证明声明本身正确。它抓得住循环依赖和未声明的共享内核,抓不住“把库存错放进 order”这种建模错误。后者要靠映射表和评审,前者靠 CI。
表驱动测试:把边界钉在用例里
测试先构造 arch-lab 的合法映射,再逐条变异,断言校验器能报出对应违规。每个用例只改一个地方,失败时能直接定位是哪条规则被绕过。
// file: internal/contextmap/map_test.go
// tier: L1
// verify: go test ./internal/contextmap/ -v
package contextmap
import (
"strings"
"testing"
)
func archLabMap() Map {
return Map{
Contexts: []Context{
{Name: "order", Kind: KindCore, Owner: "team-order", Package: "internal/order",
Owns: []string{"orders", "order_items", "order_status_history"}},
{Name: "catalog", Kind: KindCore, Owner: "team-catalog", Package: "internal/catalog",
Owns: []string{"sku", "price_book"}},
{Name: "inventory", Kind: KindSupporting, Owner: "team-inventory", Package: "internal/inventory",
Owns: []string{"stock", "inventory_reservations"}},
{Name: "fulfillment", Kind: KindSupporting, Owner: "team-fulfillment", Package: "internal/fulfillment",
Owns: []string{"shipments", "shipment_items"}},
{Name: "payment", Kind: KindGeneric, Owner: "team-payments", Package: "internal/payment",
Owns: []string{"payments", "provider_refs"}},
{Name: "identity", Kind: KindGeneric, Owner: "team-identity", Package: "internal/identity",
Owns: []string{"accounts", "credentials"}},
{Name: "notification", Kind: KindGeneric, Owner: "team-notify", Package: "internal/notification",
Owns: []string{"templates", "delivery_log"}},
},
Deps: []Dependency{
{Upstream: "catalog", Downstream: "order", Pattern: PatternCustomerSupplier, Reason: "下单读取 SKU 与价格"},
{Upstream: "order", Downstream: "inventory", Pattern: PatternCustomerSupplier, Reason: "预留与释放命令"},
{Upstream: "order", Downstream: "fulfillment", Pattern: PatternCustomerSupplier, Reason: "履约任务"},
{Upstream: "order", Downstream: "payment", Pattern: PatternCustomerSupplier, Reason: "扣款与退款命令"},
{Upstream: "identity", Downstream: "order", Pattern: PatternACL, Reason: "订单只保存 CustomerID"},
{Upstream: "order", Downstream: "notification", Pattern: PatternCustomerSupplier, Reason: "订阅订单事件"},
{Upstream: "order", Downstream: "payment", Pattern: PatternSharedKernel, Reason: "共用 Money 值对象"},
},
Kernels: []SharedKernel{
{Name: "money", Owner: "team-platform", Members: []string{"order", "catalog", "payment", "fulfillment"}},
},
}
}
func has(violations []string, fragment string) bool {
for _, v := range violations {
if strings.Contains(v, fragment) {
return true
}
}
return false
}
func TestArchLabMapIsValid(t *testing.T) {
if violations := archLabMap().Validate(); len(violations) != 0 {
t.Fatalf("arch-lab map should be valid, got %v", violations)
}
}
func TestValidateTable(t *testing.T) {
tests := []struct {
name string
mutate func(*Map)
want string
}{
{"duplicate context", func(m *Map) { m.Contexts = append(m.Contexts, m.Contexts[0]) }, "duplicate context order"},
{"missing owner", func(m *Map) { m.Contexts[0].Owner = "" }, "missing owner order"},
{"duplicate package", func(m *Map) { m.Contexts[1].Package = m.Contexts[0].Package }, "used by order and catalog"},
{"unknown dependency", func(m *Map) {
m.Deps = append(m.Deps, Dependency{Upstream: "ghost", Downstream: "order", Pattern: PatternCustomerSupplier})
}, "unknown upstream ghost"},
{"self dependency", func(m *Map) {
m.Deps = append(m.Deps, Dependency{Upstream: "order", Downstream: "order", Pattern: PatternCustomerSupplier})
}, "self dependency order"},
{"unknown kernel member", func(m *Map) {
m.Kernels[0].Members = append(m.Kernels[0].Members, "ghost")
}, "unknown kernel member money.ghost"},
{"undeclared shared kernel", func(m *Map) {
m.Deps = append(m.Deps, Dependency{Upstream: "inventory", Downstream: "fulfillment", Pattern: PatternSharedKernel})
}, "undeclared shared kernel inventory -> fulfillment"},
{"dependency cycle", func(m *Map) {
m.Deps = append(m.Deps, Dependency{Upstream: "inventory", Downstream: "order", Pattern: PatternCustomerSupplier})
}, "dependency cycle order -> inventory -> order"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := archLabMap()
tt.mutate(&m)
if violations := m.Validate(); !has(violations, tt.want) {
t.Fatalf("violations = %v, want %q", violations, tt.want)
}
})
}
}
在 darwin/arm64 + Go 1.26.0 上实际运行:
=== RUN TestArchLabMapIsValid
--- PASS: TestArchLabMapIsValid (0.00s)
=== RUN TestValidateTable
=== RUN TestValidateTable/dependency_cycle
--- PASS: TestValidateTable (0.00s)
--- PASS: TestValidateTable/dependency_cycle (0.00s)
PASS
ok archlab/internal/contextmap 0.455s
输出省略了其余子用例,但 PASS 来自完整表驱动测试。校验器一旦接入 CI,新增上下文或依赖就必须同时更新映射和测试,边界不会靠记忆维持。
一次真实变更:加“预售”会改哪些上下文
假设业务要加预售:下单时不占现货,支付成功后才转为正式库存。用映射表推演,而不是拍脑袋:
| 变更点 | 受影响上下文 | 是否改 order 聚合 | 依据 |
|---|---|---|---|
| 新增预售价格与定金 | catalog、order | 是,订单增加 sale_mode 与定金金额 | 定价是核心域,金额进入订单快照 |
| 预留改为“支付后占货” | inventory、order | 是,状态机多一条 PAID -> RESERVED 的补偿路径 | 库存规则变化不应改订单状态机,需重新评审 |
| 预售订单超时未付 | order、inventory | 是,增加定时命令 | 超时策略属于订单编排 |
这张表的价值在于暴露了第二行:它看起来是库存变更,实际会逼订单状态机加边。如果没有映射表,团队很可能直接改 inventory,然后在 order 里补一个 if,最后状态机出现两条互相矛盾的路径。
团队边界不是组织架构图
映射表里的 Owner 是问责角色,不是排班表。两人团队里,同一个人可能同时是 order 和 catalog 的 owner;这不违反“每个上下文有唯一 owner”,因为唯一 owner 约束的是“每块边界只有一个最终决定者”,不是“每个团队只能拥有一块边界”。真正要避免的是两种错位:一个上下文由两个团队共同修改领域规则,结果谁都不对状态机负责;团队边界按汇报线画,而不是按模型边界画,导致 order 团队为了“复用”直接改 inventory 的表。前者靠 Owner 字段和评审制度,后者靠包边界和依赖检查。Conway 定律在这里不是口号:包边界最终会趋同于沟通边界,映射表能让这种趋同变得可见,但不能自动阻止它。
与站内文章的分工
站内的 限界上下文策略 已经把上下文识别、Context Map 画法和共享内核、防腐层、开放主机服务讲清楚了;领域驱动设计 则覆盖了战略设计与战术设计的完整概念。本节不重复这些定义清单,只做一件事:把 arch-lab 的划分写成一张可被代码校验的映射表,并说明每条边为什么存在。
常见错误与边界
第一,把子域和团队组织图一一对应。组织会调整,模型边界不应跟着每周改;Owner 可以换,上下文职责不应换。第二,把所有“通用能力”都叫通用域。通用域的标准是“可替换、不构成差异”,不是“很多人用”;支付回调验签如果与订单幂等强耦合,就不能简单丢给通用域。
第三,共享内核无限扩张。共享内核只应包含稳定的值对象;一旦包含实体或业务规则,它就会变成跨上下文的隐式耦合。第四,只画上游下游,不写集成模式。没有模式的箭头无法回答“谁可以改契约、谁要适配谁”,评审时只能靠猜。校验器也只能保证声明一致,不能保证声明正确;把库存塞进订单聚合,它不会报错。
小结
子域划分的判据是变化频率、业务差异化和团队归属,不是功能多少。arch-lab 由此得到两个核心域(订单、定价)、两个支撑域(库存、履约)和三个通用域(身份、通知、支付网关适配)。七条限界上下文通过客户-供应商、防腐层和共享内核协作,并逐一落到 internal/* 包边界。internal/contextmap 把这张表变成可执行校验,表驱动测试守住唯一 owner、无环依赖和共享内核引用。下一节进入事件风暴工作坊,把这些边界展开成命令、事件、聚合与策略。
阅读导航:上一节:2.3 一次真实评审记录与风险台账 · 下一节:3.2 订单域事件风暴工作坊:产出聚合、事件、边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。