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,是快照
三条硬规则:
- 上下文之间不 import 对方的
domain包,只依赖对方的 API 契约或事件(依赖方向见 26.2 依赖倒置)。 - 一个上下文内部的分层按 26.5 六边形架构 组织,端口朝内。
- 跨上下文翻译只允许出现在一个地方(防腐层,见 29.2 上下文映射 (七种关系)),散落三处就等于没翻译。
反模式
| 反模式 | 后果 |
|---|---|
| 先定服务个数,再补上下文名字 | 边界由部署形态决定,模型永远对不上业务,改名三次后没人再提 DDD |
| 把整个系统画成一个上下文 | 30 个团队共用一个模型,评审会变成辩论赛 |
| 把公司组织架构图直接当上下文图 | 组织一调整,架构就崩(康威逆定律用错了方向) |
| 每个子域都拆成独立服务 | 通用域也自研,支撑域也微服务,成本翻三倍 |
| 上下文内再分子域 | 一个上下文内部只需要分层,不需要再切子域,切了就是“模型里的模型” |
| 共享内核放进“公共包”给所有人 import | 公共包变成第二个大泥球,谁都能改,谁都怕改 |
自检
Q1 一个团队说“我们有 8 个微服务,所以有 8 个限界上下文”。这句话错在哪?请给出一个反例。
▸ 答案:服务个数是部署决策,上下文个数是模型一致性决策,两者没有必然的 1:1 关系。反例:商品目录、定价、促销三个上下文常常被合成一个“商品服务”部署(同一团队、同一发布节奏),但它内部仍是三个模型,Product 有三种定义;反过来“订单服务”里同时装着订单模型和库存扣减模型,就是一个服务两个上下文,即分布式单体的雏形。
Q2 为什么 catalog 与 marketing 的字段重叠 0.71 时,第一反应不该是“合并”,而是“营销是不是读模型”?
▸ 答案:重叠高只说明字段相似,不说明规则相同。读模型的特征是“只读、由上游派生、可以随时重建”;如果营销只是按活动规则筛选/排序目录数据,它就没有自己的不变量,应该做成 CQRS 读模型。若营销有自己的不变量(活动价 ≤ 原价、活动期有效、活动库存独立扣减),那它就是独立上下文。判断依据是有没有自己的写操作与不变量,不是字段重合度。
Q3 核心域、支撑域、通用域的投入策略分别是什么?用“做得比对手好 30% 客户会不会多付钱”这条判据,给“发票开具”和“动态定价”各归一次类。
▸ 答案:核心域自研且投最强的人;支撑域自研但控成本、可外包;通用域买或开源。发票开具:全行业规则一致、换供应商无感 → 通用域,买。动态定价:直接决定毛利、对手抄不走、做好 30% 客户愿意多付 → 核心域,自研。
Q4 三条边界判据(语言、变更、团队)互相冲突时,为什么先按团队落地?
▸ 答案:因为模型边界最终要靠人来维护。语言边界可以靠包结构和命名在同一个部署单元里强制(成本低、可回退);团队边界一旦错位,代码评审、发布、值班都会被拖住,且无法靠技术手段修复。所以先让边界与团队对齐(康威定律的现实约束),再用包边界把语言边界画出来,等团队真的分开时再拆服务——这是可逆的顺序。