限界上下文与统一语言:先划边界,再谈技术
同一个词在不同团队嘴里是不同东西:运营说的「商品」指可售卖的 SKU,仓配说的「商品」指带批次和库位的实物,财务说的「商品」指挂在某个税目下的科目。如果三拨人共用一张 product 表和一个 Product 结构体,那么每次需求都会退化成「改同一份代码 + 拉齐三个人的理解」,然后靠口头约定兜底。问题不在技术选型,在边界。
1. 限界上下文解决什么问题
限界上下文(Bounded Context)是「一个领域模型有效、语言自洽、可独立演进」的范围。三个判据要同时成立:
- 模型有效:这个范围内的一套规则、约束、不变量是完整的,不需要借用外部的含义。
- 语言自洽:同一个术语在范围内只有一个含义,不需要加限定词才能说清。
- 可独立演进:改这个范围,不会被迫同时改另一个范围的内部实现。
注意:限界上下文是逻辑模型边界,不是模块、不是服务、不是数据库。它可以落成一个包、一个模块、一个进程或一个微服务——物理形态是后几章(07 模块化、08 单体、09 拆分)的事。
2. 找边界的四个抓手
| 抓手 | 怎么问 | 强信号 | 常见误判 |
|---|---|---|---|
| 业务能力 | 组织对外交付的价值环节是什么(定价、下单、履约、结算) | 该能力有独立负责人和独立指标 | 把「技术分层」当能力,如「数据组」 |
| 语言一致性 | 这个词在这里到底指什么 | 需要加限定词(订单商品 / 商品主数据)才能说清 | 用同义词掩盖分歧,如「会员」和「用户」混用 |
| 变更频率 | 哪些规则总是一起改 | 改 A 必然要重新验 B 的测试 | 按数据表数量划分,结果按 CRUD 切碎 |
| 团队边界 | 谁能端到端负责它 | 跨两个团队才能出一个功能 | 按汇报线硬切,忽略真实协作关系 |
四者冲突时的优先级:语言 > 业务能力 > 变更频率 > 团队边界。组织可以调整,语言糊了没法靠开会解决。另外,先划 3~5 个上下文即可落地,一上手划二十个只会带来二十个空目录。
3. 统一语言与术语表
统一语言(Ubiquitous Language)指的是:业务方、需求文档、代码里的类名/方法名/表名/接口字段,用的是同一批词。做法是按上下文维护术语表,不要建一张全局大表——全局表会立刻退化成「同一个词列四五种含义」,等于没有。
| 上下文 | 术语 | 含义 | 明确不指 |
|---|---|---|---|
| 订单 | 订单 | 客户一次购买意图的聚合,含成交价快照 | 不等于发货单 |
| 订单 | 订单项 | 订单内某 SKU 的成交数量与成交价 | 不等于购物车行 |
| 库存 | 可售库存 | 总库存 − 已锁定 − 残次 | 不等于实物库存 |
| 支付 | 支付单 | 一次支付尝试及其状态 | 不等于订单 |
术语表里「明确不指」这一列最容易被省略,却最有用:它把歧义提前钉死。同时要维护一份禁用词表,例如全局禁止 data、info、manager、biz、util 这类不表达领域含义的命名。
4. 把语言固化进代码
// 订单上下文:类型名、常量名直接使用术语表里的词
type OrderID string
type SKUID string
type Money struct { // 值对象:金额一律用「分」,避免浮点
Amount int64
Currency string
}
// 可售库存:术语表里精确到「可售」,不要写成 Stock
func (s *SellableStock) Available() int { return s.total - s.locked - s.defective }
命名是边界的免费体检工具:如果一个包里的类型名需要靠注释解释「这里的 Product 其实是……」,说明这个包混了两个上下文。
5. 上下文映射:关系定下来,成本才可算
上下文映射(Context Mapping)描述上下文之间的协作方式与权力关系。方向(谁是上游)决定谁承担兼容成本。
| 关系 | 方向与含义 | 代价 | 适用时机 |
|---|---|---|---|
| 合作关系(Partnership) | 双向,成败绑定,一起排期 | 协调成本高 | 两个上下文由一支团队共同负责 |
| 共享内核(Shared Kernel) | 双向,共用一小块模型/代码 | 任何改动都要双方同意 | 内核足够小、且确有共同负责人 |
| 客户-供应商(Customer-Supplier) | 上游向下游承诺接口 | 上游要排下游的需求 | 上游愿意配合、有明确接口契约 |
| 遵奉者(Conformist) | 下游直接采用上游模型 | 上游模型污染下游语言 | 上游强势且模型稳定,改造不划算 |
| 防腐层(Anticorruption Layer) | 下游加翻译层隔离上游模型 | 多一层映射代码与维护成本 | 上游模型与本地语言差异大、且会变 |
| 开放主机服务 + 发布语言 | 上游提供统一入口与公开契约 | 上游成本前置 | 一个上游服务多个下游消费 |
| 各行其道(Separate Ways) | 不集成,各做各的 | 功能重复 | 集成成本高于重复实现的收益 |
选择上的现实建议:上游不配合时,短期「遵奉者」最快,但要把污染范围限制在边界层;只要上游模型语义和本地语言差得远,就上防腐层,别让外部 DTO 流进领域模型。
6. 防腐层的最小实现
// 外部支付平台的报文(外部语言,绝不允许直接进入订单上下文)
type payNotify struct {
TradeNo string `json:"trade_no"`
TotalFee int64 `json:"total_fee"` // 单位:分
PayState string `json:"pay_state"`
}
// 防腐层:只做一件事——翻译成订单上下文自己的语言
type PayACL struct{}
func (PayACL) ToPaid(n payNotify) (order.Paid, error) {
if n.PayState != "TRADE_SUCCESS" {
return order.Paid{}, fmt.Errorf("非成功支付态: %s", n.PayState)
}
if n.TotalFee <= 0 {
return order.Paid{}, errors.New("支付金额非法")
}
return order.Paid{
OrderNo: order.OrderID(n.TradeNo),
Amount: order.Money{Amount: n.TotalFee, Currency: "CNY"},
}, nil
}
判断防腐层是否做对了:把它删掉,领域包里任何类型都不该出现 payNotify、TradeNo、TRADE_SUCCESS 这类外部词。
7. 落地检查项
- 每个上下文有一份术语表,且术语表里的词能在代码里 grep 到。
- 上下文之间的依赖有明确方向,上游变更必须走契约而不是直接改下游。
- 没有「万能共享包」;共享内核小到一次重构能改完,且有明确负责人。
- 外部系统的模型只出现在边界适配层,领域包不 import 外部 SDK 的模型类型。
- 能说清每个上下文的负责人,以及它一次发布的最小范围。
8. 常见误区
- 把上下文当微服务:一划边界就拆进程,结果边界没想清、运维成本先翻倍。先逻辑后物理。
- 一个上下文等于一张表前缀:物理隔离了,模型仍然互借字段,等于共享内核失控。
- 共享内核越滚越大:共享内核(Shared Kernel)的代价是双方必须同步发版,只应放最稳定的少数概念。
- 术语表写完就烂尾:术语表要随代码评审更新;更硬的做法是把关键术语写进类型名和测试用例,让代码迫使它保持准确。