29.1 领域、子域、限界上下文

一句话

领域是“这门生意本身”,子域是“把生意按值不值钱切开的刀”,限界上下文是“一个模型能够自洽的边界”——前两者在问题空间,第三者才在解空间;把三个词当成同一张图的三个名字,是 DDD 落地失败的第一个原因。

三个词,两个空间

概念 回答的问题 所属空间 划分依据 谁说了算
领域 Domain 软件服务的是哪门生意 问题空间 业务范围 业务方
子域 Subdomain 这门生意里哪部分值钱 问题空间 业务能力 / 价值链 业务方 + 架构师
限界上下文 Bounded Context 哪个范围内的术语与规则必须一致 解空间 模型一致性 架构师 + 团队

🔑 一句话记住:子域是你“发现”的,限界上下文是你“设计”的。前者客观存在(业务就是这么分工的),后者是你的建模决策(可以画大也可以画小,只要边界自洽)。

破除三个误区

误区 现实
一个子域 = 一个限界上下文 1:1 只在最简单时成立。一个子域常拆成多个上下文(商品 → 商品目录 / 库存 / 营销 / 交易快照);多个子域也可能共用一个上下文(小系统里“会员 + 积分”常常就是同一个上下文)
限界上下文 = 微服务 上下文是模型边界,微服务是部署边界。一个上下文可以先落成模块化单体里的一个模块(27.2 模块化单体),以后再拆;反过来“一个服务里塞三个上下文”就是分布式单体(30.3 分布式单体反模式)
领域模型 = 数据库表 + 实体类 数据库表是持久化结构,领域模型是行为 + 不变量 + 语言。只有 getter/setter 的实体是贫血模型,规则散落在 service 里,模型名存实亡

子域的三种类型:钱该花在哪

类型 定义 判据 投入策略 电商例子
核心域 Core 公司靠它赚钱,且对手抄不走 有差异化竞争力 自研、最强的人、长期投入 交易撮合、动态定价、风控策略
支撑域 Supporting 必须有,但不产生差异化 行业通用,但需贴合自身流程 自研但控成本,可外包 仓储作业、售后工单、结算对账
通用域 Generic 全行业都一样 买得到成熟产品 买 / 开源 / SaaS 短信、支付通道、发票、IAM

识别核心域的方法(价值链分析)是 2.3 价值链与核心域识别的主场,这里只给一条可当场用的判据:

“这块如果做得比对手好 30%,客户会不会为此多付钱?” 会 → 核心域;不会但停摆 → 支撑域;换供应商无感 → 通用域。

⚠️ 两类错误的预算表长得一模一样:把支撑域当核心域做,最贵的人力投在最不值钱的地方;把核心域当通用域买,把自己的命交给供应商。

同一个词,四个模型:Product

电商里“商品”是最典型的例子。它在四个上下文里根本不是同一个东西:

上下文 这个“商品”是什么 关键属性 不变量
商品目录 catalog 可展示的售卖单元 标题、图片、类目、上下架状态 上架必须有主图与类目
库存 inventory 可承诺的实物数量 现货、锁定、仓库 可售 = 现货 − 锁定 ≥ 0
营销 marketing 可打折的标的 活动价、门槛、活动库存 活动价 ≤ 原价,且活动期有效
交易 trade 下单那一刻的快照 成交价、数量、税费 下单后价格不随目录变动

🔑 Product 在四个上下文里有四个不同的类型:20 个字段里只有 id 一个被四个上下文同时需要,其余 19 个都只是“部分人的事”。硬把它们合成一个共享实体,等于让四个团队共用一个结构体——每改一个字段都要四方评审。

下面这段程序把这笔账算出来:把一个“共享 Product 实体”的 20 个字段按使用方归类,看共享内核到底有多大。

package main

import (
	"fmt"
	"sort"
)

// 一个“共享 Product 实体”的 20 个字段,以及每个字段被哪些上下文使用。
// 数据是一张典型电商商品表在四个上下文里的实际用法,可替换成你自己的表。
var usage = map[string][]string{
	"id":           {"catalog", "inventory", "marketing", "trade"},
	"title":        {"catalog", "marketing", "trade"},
	"sub_title":    {"catalog", "marketing"},
	"description":  {"catalog", "marketing"},
	"brand_id":     {"catalog", "marketing"},
	"category_id":  {"catalog", "marketing"},
	"image_urls":   {"catalog", "marketing", "trade"},
	"attrs_json":   {"catalog", "marketing"},
	"list_price":   {"catalog", "marketing"},
	"sale_price":   {"catalog", "marketing", "trade"},
	"promo_price":  {"marketing", "trade"},
	"cost":         {"marketing"},
	"on_hand":      {"inventory"},
	"locked":       {"inventory"},
	"warehouse_id": {"inventory"},
	"weight_g":     {"inventory", "trade"},
	"tax_code":     {"trade"},
	"status":       {"catalog"},
	"audit_status": {"catalog"},
	"supplier_id":  {"inventory"},
}

var contexts = []string{"catalog", "inventory", "marketing", "trade"}

func main() {
	total := len(usage)

	// ① 按“被几个上下文使用”分组:被 ≥3 个用的,才是共享内核的候选
	fmt.Println("=== ① 字段按使用方数量分组 ===")
	buckets := map[int][]string{}
	for f, cs := range usage {
		buckets[len(cs)] = append(buckets[len(cs)], f)
	}
	for _, n := range []int{4, 3, 2, 1} {
		fs := buckets[n]
		sort.Strings(fs)
		fmt.Printf("被 %d 个上下文使用: %2d 个字段 (%3.0f%%)  %v\n",
			n, len(fs), float64(len(fs))*100/float64(total), fs)
	}

	// ② 每个上下文对这个共享实体的使用率:剩下的字段全是“别人的”
	fmt.Println("\n=== ② 每个上下文用到多少字段 ===")
	for _, c := range contexts {
		fmt.Printf("%-10s 用到 %2d/%d 字段 (%3.0f%%),%2d 个字段与它无关\n",
			c, len(setOf(c)), total, float64(len(setOf(c)))*100/float64(total), total-len(setOf(c)))
	}

	// ③ 两两重叠(Jaccard):重叠极高的两个上下文,很可能其中一个是另一个的读模型
	fmt.Println("\n=== ③ 上下文两两字段重叠 (Jaccard) ===")
	for i := 0; i < len(contexts); i++ {
		for j := i + 1; j < len(contexts); j++ {
			a, b := setOf(contexts[i]), setOf(contexts[j])
			inter := 0
			for f := range a {
				if b[f] {
					inter++
				}
			}
			union := len(a) + len(b) - inter
			fmt.Printf("%-10s ∩ %-10s 共同字段 %2d 个, Jaccard = %.2f\n",
				contexts[i], contexts[j], inter, float64(inter)/float64(union))
		}
	}
}

// setOf 返回某个上下文实际用到的字段集合
func setOf(ctx string) map[string]bool {
	s := map[string]bool{}
	for f, cs := range usage {
		for _, x := range cs {
			if x == ctx {
				s[f] = true
			}
		}
	}
	return s
}

本机实测(macOS,Apple M1,Go 1.27):

=== ① 字段按使用方数量分组 ===
被 4 个上下文使用:  1 个字段 (  5%)  [id]
被 3 个上下文使用:  3 个字段 ( 15%)  [image_urls sale_price title]
被 2 个上下文使用:  8 个字段 ( 40%)  [attrs_json brand_id category_id description list_price promo_price sub_title weight_g]
被 1 个上下文使用:  8 个字段 ( 40%)  [audit_status cost locked on_hand status supplier_id tax_code warehouse_id]

=== ② 每个上下文用到多少字段 ===
catalog    用到 12/20 字段 ( 60%), 8 个字段与它无关
inventory  用到  6/20 字段 ( 30%),14 个字段与它无关
marketing  用到 12/20 字段 ( 60%), 8 个字段与它无关
trade      用到  7/20 字段 ( 35%),13 个字段与它无关

=== ③ 上下文两两字段重叠 (Jaccard) ===
catalog    ∩ inventory  共同字段  1 个, Jaccard = 0.06
catalog    ∩ marketing  共同字段 10 个, Jaccard = 0.71
catalog    ∩ trade      共同字段  4 个, Jaccard = 0.27
inventory  ∩ marketing  共同字段  1 个, Jaccard = 0.06
inventory  ∩ trade      共同字段  2 个, Jaccard = 0.18
marketing  ∩ trade      共同字段  5 个, Jaccard = 0.36

四条读数,直接决定架构:

读数 含义 动作
被 ≥3 个上下文共用的只有 4 个字段(20%) 真正的“共享内核”只有 20% 把这 4 个做成独立的共享包,其余字段不共享
每个上下文平均只用到 9.25/20(46%) 一半以上字段是“别人的”,改它就是帮别人改 共享实体不成立,改为 ID 引用 + 各自模型
有 8 个字段(40%)只被一个上下文使用 私货被塞进了公共表 拆回各自上下文
catalog ∩ marketing = 0.71 两者字段高度重合 先问“营销是不是只是目录的一个读模型?”是 → CQRS 读模型(32.1 CQRS),不是两个上下文

最后一条是本节最反直觉的地方:重叠高不一定是“该合并”,也可能是“其中一个根本不该有独立模型”。库存与所有人都低于 0.2,说明它是真正独立的上下文;营销与目录 0.71,说明它更像一个派生视图。

边界怎么定:三条可当场用的判据

判据 问法 边界画在哪
语言判据 同一个词,两个地方定义一样吗? 定义不同的地方中间(订单在交易里是“购买承诺”,在履约里是“待发货任务”,在财务里是“应收凭据”)
变更判据 两组规则会因为不同原因、不同时间被改吗? 会 → 分开(同 26.6 模块化与包设计 的 CCP 口径)
团队判据 两个团队对同一个模型都有修改权吗? 有 → 要么合团队,要么切开(74.1 康威定律及其逆定律)

三条冲突时(语言说拆、团队说合),先按团队落地,再用语言边界预留:同一个服务里用包边界切开,等团队分开时再拆服务。

上下文在代码里长什么样

上下文不是一个文档章节,它必须在代码里有物理痕迹,否则半年后就消失了:

internal/
  catalog/            ← 一个限界上下文一个包
    domain/           ← 只有这个上下文认识的类型:Product{标题,图片,类目,状态}
    app/              ← 用例:上架、下架
    infra/            ← 持久化、外部适配器
  inventory/          ← 另一个上下文,也有 Product,但字段完全不同
    domain/           ← Product{现货,锁定,仓库}
  trade/
    domain/           ← 这里的 Product 叫 OrderLine,是快照

三条硬规则:

  1. 上下文之间不 import 对方的 domain 包,只依赖对方的 API 契约或事件(依赖方向见 26.2 依赖倒置)。
  2. 一个上下文内部的分层按 26.5 六边形架构 组织,端口朝内。
  3. 跨上下文翻译只允许出现在一个地方(防腐层,见 29.2 上下文映射 (七种关系)),散落三处就等于没翻译。

反模式

反模式 后果
先定服务个数,再补上下文名字 边界由部署形态决定,模型永远对不上业务,改名三次后没人再提 DDD
把整个系统画成一个上下文 30 个团队共用一个模型,评审会变成辩论赛
把公司组织架构图直接当上下文图 组织一调整,架构就崩(康威逆定律用错了方向)
每个子域都拆成独立服务 通用域也自研,支撑域也微服务,成本翻三倍
上下文内再分子域 一个上下文内部只需要分层,不需要再切子域,切了就是“模型里的模型”
共享内核放进“公共包”给所有人 import 公共包变成第二个大泥球,谁都能改,谁都怕改

自检

Q1 一个团队说“我们有 8 个微服务,所以有 8 个限界上下文”。这句话错在哪?请给出一个反例。

▸ 答案:服务个数是部署决策,上下文个数是模型一致性决策,两者没有必然的 1:1 关系。反例:商品目录、定价、促销三个上下文常常被合成一个“商品服务”部署(同一团队、同一发布节奏),但它内部仍是三个模型,Product 有三种定义;反过来“订单服务”里同时装着订单模型和库存扣减模型,就是一个服务两个上下文,即分布式单体的雏形。

Q2 为什么 catalog 与 marketing 的字段重叠 0.71 时,第一反应不该是“合并”,而是“营销是不是读模型”?

▸ 答案:重叠高只说明字段相似,不说明规则相同。读模型的特征是“只读、由上游派生、可以随时重建”;如果营销只是按活动规则筛选/排序目录数据,它就没有自己的不变量,应该做成 CQRS 读模型。若营销有自己的不变量(活动价 ≤ 原价、活动期有效、活动库存独立扣减),那它就是独立上下文。判断依据是有没有自己的写操作与不变量,不是字段重合度。

Q3 核心域、支撑域、通用域的投入策略分别是什么?用“做得比对手好 30% 客户会不会多付钱”这条判据,给“发票开具”和“动态定价”各归一次类。

▸ 答案:核心域自研且投最强的人;支撑域自研但控成本、可外包;通用域买或开源。发票开具:全行业规则一致、换供应商无感 → 通用域,买。动态定价:直接决定毛利、对手抄不走、做好 30% 客户愿意多付 → 核心域,自研。

Q4 三条边界判据(语言、变更、团队)互相冲突时,为什么先按团队落地?

▸ 答案:因为模型边界最终要靠人来维护。语言边界可以靠包结构和命名在同一个部署单元里强制(成本低、可回退);团队边界一旦错位,代码评审、发布、值班都会被拖住,且无法靠技术手段修复。所以先让边界与团队对齐(康威定律的现实约束),再用包边界把语言边界画出来,等团队真的分开时再拆服务——这是可逆的顺序。

关联