限界上下文与统一语言:先划边界,再谈技术

同一个词在不同团队嘴里是不同东西:运营说的「商品」指可售卖的 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)的代价是双方必须同步发版,只应放最稳定的少数概念。
  • 术语表写完就烂尾:术语表要随代码评审更新;更硬的做法是把关键术语写进类型名和测试用例,让代码迫使它保持准确。

小结:边界由语言和变更频率决定,不由技术栈决定;先让每个上下文拥有自己的模型、词汇表和负责人,再讨论包怎么拆、服务怎么分。

笔记加载中…