《后端架构设计·架构基础与边界》3.1 子域、限界上下文与团队边界

以 arch-lab 订单系统为样本,按变化频率、业务差异化与团队归属划出核心域、支撑域和通用域,给出七条限界上下文的职责、数据、上下游、集成模式与包边界对照表,并把映射表编码成可校验的 internal/contextmap 包,用表驱动测试守住唯一 owner、无环依赖和共享内核引用。

先把 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-orderinternal/order
catalogSKU、价格簿、价格快照sku、price_book、price_snapshot上游:order客户-供应商team-cataloginternal/catalog
inventory可售量、按 SKU/仓预留与释放stock、inventory_reservations下游:order 的预留命令;上游:order 的订单事实客户-供应商team-inventoryinternal/inventory
fulfillment发货单、拆包、签收shipments、shipment_items下游:order客户-供应商team-fulfillmentinternal/fulfillment
payment支付单、退款单、渠道回调payments、provider_refs、refunds下游:order;上游:支付渠道客户-供应商;渠道侧防腐层team-paymentsinternal/payment
identity账号、凭据、租户accounts、credentials、tenants上游:order防腐层team-identityinternal/identity
notification模板、渠道投递、失败重试templates、delivery_log下游:order客户-供应商(事件订阅)team-notifyinternal/notification

映射表里有三条约束值得单独说明。

第一,order 不拥有库存数据。它只保存 InventoryReservation 的引用和状态摘要,不保存 stock;库存是否可预留由 inventory 在自己的事务里判断,order 只负责把“预留失败”翻译成 OUT_OF_STOCK。第二,catalog 是 order 的上游,但 order 保存价格快照。下单那一刻把 UnitPriceCents 写进 OrderItem,之后价格簿怎么改都不回写历史订单。第三,identity 到 order 用防腐层。订单只需要 CustomerID 和一个“该客户是否可下单”的判断,不需要账号、凭据、租户这些模型;没有防腐层,identity 改一次登录方式,order 的测试就会跟着坏。

与模块化单体的包边界对齐

ADR-001 选择模块化单体,所以上下文边界必须落到 Go 包边界,否则映射表只是一张墙上的图。对齐关系如下:

上下文包允许依赖禁止依赖
orderinternal/order标准库、internal/shared/moneyadapters、platform、其他上下文
cataloginternal/catalog标准库、internal/shared/moneyadapters、platform、order
inventoryinternal/inventory标准库order、payment、adapters
fulfillmentinternal/fulfillment标准库、internal/shared/moneyorder、inventory、adapters
paymentinternal/payment标准库、internal/shared/moneyorder、adapters(渠道适配除外)
identityinternal/identity标准库其他上下文、adapters
notificationinternal/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 订单域事件风暴工作坊:产出聚合、事件、边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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