Chapter 03

限界上下文:给每块子域画一条语义边界

上一章在问题空间发现了子域,本章进入解空间——给每块子域画一个语义边界。子域是业务对现实的切分,限界上下文是模型对子域的回应:在一条边界之内,一套统一语言只有一种含义。

本章你将建立的 schema

  • 一个词在不同上下文里就是不同的类——代码无法像对话那样靠语境自动消歧
  • 限界上下文是「一个模型 + 一套统一语言」一致的边界,企业级统一模型被放弃
  • 上下文是模型/语言边界,先于微服务存在,单体里同样成立
  • 上下文映射给每条边界缝隙命名:9 种关系,各有适用场景和代价
  • 防腐层在缝隙处显式翻译,挡住外部模型对本上下文语言的腐蚀
  • 流对齐团队约等于一个限界上下文——康威定律可以被反过来用

全章用「云市」电商交易平台贯穿。它有五个限界上下文:目录 Catalog(商品展示)、销售 Sales(下单与订单状态机,核心域)、库存 Inventory(可用量与仓位)、配送 Shipping(运单与签收)、支付 Payment(收款与退款)。这五块边界是本章从头到尾的工作台。

3.1一词多义 Polysemy:代码无法靠语境自动消歧

同一个业务词在不同上下文里指向不同的对象;对话能靠语境消歧,代码不能——一个统一类会把矛盾的字段全堆在一起。

为什么需要它

人说「商品」时,听者会自动用语境补全:仓库管理员说的「商品」是货架上的数量,运营说的「商品」是详情页的图文。一词多义(polysemy / 一词多义)在口语里几乎无成本。代码没有语境——一个 Product 类被所有人 import,它必须同时满足所有人的「商品」,于是字段越长越自相矛盾。

底层机制(比定义深一层):把云市的「商品 Product」摊开看,它在四个上下文里是四个不同对象。目录 Catalog 里它很「胖」:标题、详情、图集、类目、参数、营销文案。销售 Sales 里它是一张下单快照:productId + 名称快照 + 单价快照——价格在下单那一刻被冻结,之后目录改价不影响已下的订单。库存 Inventory 里它只是 Sku + 可用数量 + 仓位,不关心标题和图片。配送 Shipping 里它退化成重量、体积、件数。强行做一个企业级统一的 Product,等于让一个类同时背负营销文案和仓位编号,每个使用方都被迫看见自己永远不用的一半字段。

「客户 Customer」是同一根刺:销售 Sales 里它是下单方 买家 Buyer;支付 Payment 里它是付款方 付款方 Payer(账户、支付方式);营销里它是会员 Member(等级、积分)。Buyer、Payer、Member 在数据库里往往指向同一个自然人,但它们在各自上下文里要回答的问题完全不同,硬合成一个 Customer 类只会让三组逻辑互相绊脚。

一个「商品」,五种形状 目录 Catalog 标题 / 详情 图集 / 类目 参数 / 文案 很「胖」 销售 Sales productId 名称快照 单价快照 下单快照·冻结 库存 Inventory Sku 可用数量 仓位 只管数量 配送 Shipping 重量 体积 件数 只管运它 营销 活动归属 折扣资格 曝光位 只管卖点 同一个 productId,五套互不相同的字段 —— 没有哪一套是「正确」的商品
图 3.1一个「商品」在五个上下文里是五个对象。注意:它们共享的只有一个 productId(标识),字段集各不相同。试图合并成一个企业级 Product 类,等于把这五栏字段全塞进一个类。
类比 · 带边界声明

像自然语言里的「苹果」:在水果摊、在手机店、在唱片公司各指一物,听者靠场景秒懂。类比失效处:自然语言的消歧由听者大脑完成,是隐式的、即时的;代码里没有这个听者,消歧必须被显式建模成「不同上下文里不同的类」——这正是下一节限界上下文要做的事。

与下一节的关系:既然一个词在不同地方就是不同的对象,那就需要一条边界把「这个词在这里的唯一含义」框起来。这条边界就是限界上下文。

3.2限界上下文 Bounded Context:一套语言一致的边界

限界上下文(Bounded Context)是一个模型、一套统一语言(Ubiquitous Language)保持一致的明确边界;同一个词在两个上下文里就是两个不同的类。

为什么需要它

01 章把统一语言定义成「绑住口语和代码的绳子」。但一根绳子拉不到全公司——拉得越远,「商品」「客户」这些词被不同部门赋予的含义就越多,语言开始内部打架。限界上下文是给统一语言划的作用域:在这条线以内,每个术语只有一个含义、对应一个类;跨过这条线,同名术语的含义需要重新协商。

底层机制(比定义深一层):限界上下文的「界」限的是语言和模型,不是数据或代码目录。判断两段业务该不该同属一个上下文,标准是一个词在这里是否始终只有一个含义。云市的 Sales 上下文里,「商品」永远指那张价格冻结的下单快照、「客户」永远指 Buyer,团队、产品、代码用的是同一套词;Inventory 上下文里「商品」永远指 Sku + 数量。两个上下文都有「商品」这个词,但它们映射到两个不同的类——这不是命名冲突,而是事实:仓库关心的商品和详情页关心的商品本就不是一回事。

这条边界带来一个让很多工程师不适的结论:放弃企业级统一模型。受「一个数据模型治天下」直觉训练的人,会本能地想把所有「商品」合并成一张表、一个类,再用一堆可空字段和 if (来源 == 库存) 兼容各方。限界上下文反着走——它承认每个上下文有自己的模型,不追求全局唯一,只追求局部一致。两个上下文之间不靠共享类协作,而靠在缝隙处显式翻译(3.4、3.5 节)。代价是多了几套看起来相似的类和一层翻译;收益是每个模型都干净、能独立演化,改 Catalog 的图文结构不会惊动 Inventory。

类比 · 带边界声明

像不同国家的「一楼」:中文的一楼是地面层,英式英语的 first floor 是地面往上第二层。各自国内沟通毫无歧义,跨国时必须显式换算。类比失效处:国家边界是地理给定的,而限界上下文的边界是设计决策——画在哪、画几条,是建模者根据语言一致性主动切的,画错了就要重画。

想一想

云市运营提议:把 Catalog 的胖 Product 类直接拿到 Sales 下单时复用,「反正都是商品,省得再建一个类」。如果照做,三个月后 Catalog 团队想给 Product 加一个「短视频介绍」字段,会牵连到谁?下单时被冻结的「单价快照」又会出什么问题?

停 10 秒,再展开

加「短视频介绍」字段会牵连 Sales:因为它们共享同一个类,Catalog 的任何结构变动都要 Sales 跟着编译、回归。更糟的是单价——Sales 需要的是下单那一刻冻结的价格快照,而 Catalog 的 Product 持有的是当前最新价。复用同一个类,要么 Sales 拿到的是会变的价(违反「价格冻结」不变量,用户付款时金额变了),要么 Catalog 被迫为 Sales 保留一个它根本不用的快照字段。两个上下文对「商品」的诉求本质不同,共享类把它们焊死在一起。正确做法:Sales 有自己的下单快照类,在边界处从 Catalog 翻译过来——这就是 3.5 节的防腐层。

与下一节的关系:很多人一看到「边界」就联想到微服务、独立部署。但限界上下文的边界比部署边界更早、更基础,下一节先把这两者拆开。

3.3上下文 ≠ 部署 / 微服务边界

限界上下文是模型与语言的边界,它先于微服务存在;在一个单体应用里同样成立,不需要任何独立部署。

为什么需要它

「限界上下文就是微服务」是这门课程要重置的旧直觉之一。把两者画等号会导致两类错误:一是为了「做 DDD」把单体强行拆成一堆微服务,背上分布式事务和网络故障的全部成本;二是反过来,因为「现在是单体」就认为不需要划上下文,于是「商品」「客户」在一个工程里继续混成一团。

底层机制(比定义深一层):限界上下文是一条逻辑边界——它规定「在这一片代码和语言里,每个术语只有一个含义」。微服务是一条物理边界——它规定「这一块独立部署、独立进程、独立数据库」。逻辑边界先于物理边界:你可以在一个单体里用模块(Java 的包/模块、独立的持久化映射)把 Catalog、Sales、Inventory 干净地隔成三个限界上下文,它们各有各的 Product 类、互不 import 对方的领域对象,仅通过应用层接口或进程内事件协作。这就是一个划好了上下文的模块化单体(modular monolith),一行微服务都没有。

顺序很重要:先有清晰的限界上下文,微服务才可能从边界处自然涌现。如果某个上下文后来确实需要独立伸缩或独立发布,把它从单体里「切」出去成本很低——因为边界早就在那了。反过来,没划清上下文就先拆服务,等于在一团泥里硬切,切出来的服务还在互相调用对方的内部模型,得到的是「分布式单体」:网络开销全有,解耦一点没有。这条线 08 章讲架构时会展开「一个限界上下文 = 一个微服务」为何已被推翻、模块化单体为何优先。

常见错误 · 把逻辑边界当物理边界

「我们要上 DDD,所以先把系统拆成 20 个微服务」——这是把因果倒置。DDD 给的是在哪划边界,没说边界必须用网络隔开。先在单体里把上下文划对、让每个上下文的语言自洽,是几乎零风险的;把它们拆成独立服务则引入分布式系统的全部代价,这笔账要单独算,且大多数团队算下来的结论是:暂时别拆。

与下一节的关系:五个上下文各自语言自洽之后,它们之间仍要协作——Sales 下单要查 Catalog 的商品、要让 Inventory 预留库存。这些协作关系不能含糊,每条缝隙都得有名字。这就是上下文映射。

3.4上下文映射 Context Map:给每条缝隙命名

上下文映射(Context Map)画出系统里所有限界上下文及它们之间的关系,给每条边界缝隙起一个名字——关系的种类决定了谁迁就谁、翻译在哪一侧、改动如何传播。

为什么需要它

划好上下文只解决了「每块内部一致」,没解决「块与块之间怎么对接」。两个上下文协作时,谁的语言说了算、上游改了下游怎么办、要不要挡一层翻译——这些如果不命名、不画出来,就会退化成「谁急谁妥协」的临时拉扯。上下文映射把这些关系显式化,让团队对「我们是什么关系」达成共识。

底层机制(比定义深一层):每条关系都隐含一个权力方向(谁是上游 upstream、谁是下游 downstream,上游的改动顺流冲击下游)和一个翻译策略(在边界处翻不翻、谁来翻)。Evans 蓝皮书把常见关系归纳成下面九种。读这张表时,主线是三件事:这是什么关系 → 何时适合用 → 代价是什么。

表 3.1 · 上下文映射的九种关系
关系何时用代价
共享内核
Shared Kernel
两个上下文共享一小块核心模型/代码(如共用一套 Money),且双方团队愿意紧密协同 任何一方改共享部分都要双方同意;耦合最紧,共享得越多越脆
客户-供应商
Customer-Supplier
上游(供应商)愿意把下游(客户)的需求纳入自己的迭代计划,如 Inventory 为 Sales 提供库存预留 需要稳定的协商节奏;上游必须真把下游需求排进 backlog,否则名存实亡
遵奉者
Conformist
上游模型够用且无意配合下游,下游决定直接沿用上游模型、不做翻译 下游被上游的模型同化,失去自己语言的纯净;上游一变下游全变
防腐层
ACL
必须依赖一个外部/遗留模型,但要保护自己语言不被污染,如 Sales 调 Catalog 多一层翻译代码要写、要维护;翻译规则随上游演化要跟着改
开放主机服务
Open Host Service
一个上游被很多下游依赖,于是定义一套公开、稳定的协议对外服务 公开协议一旦发布就难改,要做版本管理;约束了上游自身的演化自由
发布语言
Published Language
多方交换数据时,约定一套共享的、文档化的交换格式(常与 OHS 搭配) 需要治理和版本;交换格式≠任一方的内部模型,要额外维护映射
合作
Partnership
两个上下文成败绑定、必须一起演进,团队选择共担计划与集成 双方协调成本高;一方延期会拖住另一方,独立性最差
各行其道
Separate Ways
两个上下文集成的收益低于成本,干脆不集成,各做各的 放弃复用,可能出现重复实现;前提是确认它们真的不需要协作
大泥球
Big Ball of Mud
识别出一片模型混杂、边界含糊的既有系统(常是遗留单体),先圈出来、承认它就是一团,不假装里面有清晰模型 内部已无清晰模型可言;对策不是"在里面做 DDD",而是别让它的混乱漏过边界——新上下文用防腐层把它挡在外面

(这九种里,开放主机服务与发布语言常成对出现;大泥球不是一种"设计",而是对既有遗留烂摊子的诚实标记——提醒你在边界处用防腐层把混乱挡在外面。)读这张表的关键不是背名字,而是给云市的每条缝隙挑一个。把云市的五个上下文连起来,就得到下面这张上下文映射图。

销售 Sales 核心域 目录 Catalog 库存 Inventory 支付 Payment 配送 Shipping ACL 防腐层 客户-供应商 客户-供应商 客户-供应商 核心域 Sales 居中:对外部胖模型用 ACL,对配套上下文用客户-供应商
图 3.2云市的上下文映射。注意:Sales 是核心域,对会变结构的外部 Catalog 用防腐层(朱红)隔离;对 Inventory / Payment / Shipping 这些为它服务的上下文,用客户-供应商关系,由 Sales 作为客户把需求排进对方迭代。

下一节先把最常用、也最能保护核心域的那条关系——防腐层——讲透。

与下一节的关系:九种关系里,防腐层是核心域保护自己语言的主力武器。Sales 是云市的核心域,它和外部上下文打交道时几乎都该带一层防腐层。下一节钻进去看它具体翻译什么。

3.5防腐层 ACL:在缝隙处显式翻译

防腐层(Anticorruption Layer, ACL)是夹在两个上下文之间的一层翻译,把外部模型转换成本上下文需要的形状,挡住外部概念渗进来腐蚀本地语言。

为什么需要它

Sales 下单时需要商品信息,这些信息来自 Catalog。但 Catalog 的 Product 是个胖对象(标题、图集、类目、营销文案……),Sales 真正需要的只有 productId、名称、当前价这三样,且要把价格冻结成快照。如果 Sales 直接持有 Catalog 的 Product,Catalog 的字段、改价、结构变动就会顺着这根引用,一路腐蚀进 Sales 的核心模型。

底层机制(比定义深一层):防腐层是 Sales 一侧写的一个翻译组件(通常是一个 Translator + 一个本地接口)。它对外调用 Catalog(或 Catalog 的开放主机服务),对内只吐出 Sales 自己的领域对象。外部模型的任何概念都到此为止——Catalog 的 CatalogProduct 永远不会越过这层进入 Sales 的领域代码。下面看翻译的两侧。

外部模型:Catalog 的胖 Product(Sales 不想直接碰它)Java
// Catalog 上下文对外暴露的「商品」——很胖,且 price 是「当前价」会变
public class CatalogProduct {
    String productId;
    String title;
    List<String> imageUrls;     // Sales 不关心
    String categoryPath;        // Sales 不关心
    String marketingCopy;       // Sales 不关心
    Money  currentPrice;        // 注意:当前价,随时可能调整
}
防腐层:Sales 一侧的翻译,产出本地的下单快照Java
// Sales 自己的领域对象:下单快照,价格在下单那一刻被冻结
public record OrderLineSnapshot(String productId, String nameSnapshot, Money unitPrice) {}

// 防腐层:唯一允许认识 CatalogProduct 的地方
public class CatalogAcl {
    private final CatalogClient catalog;   // 调外部开放主机服务

    public OrderLineSnapshot snapshotFor(String productId) {
        CatalogProduct ext = catalog.fetch(productId);   // 外部胖对象,到此为止
        // 只取 Sales 需要的三样,并把「当前价」冻结成「单价快照」
        return new OrderLineSnapshot(
            ext.productId,
            ext.title,                  // 翻译:title -> nameSnapshot
            ext.currentPrice            // 翻译:currentPrice -> 冻结的 unitPrice
        );
    }
}

翻译做了三件事,每件都在保护 Sales 的语言:裁剪——丢掉图集、类目、文案这些 Sales 永不使用的字段;改名对齐——Catalog 的 title 进入 Sales 后叫 nameSnapshot,名字落进 Sales 的统一语言;语义冻结——Catalog 的 currentPrice(会变)被翻译成 Sales 的 unitPrice(下单时刻冻结),从此目录改价不再影响这张订单。没有这层,Sales 的 Order 聚合就会直接握着一个会变价、带一堆无关字段的外部对象,「单价快照」这条不变量根本立不住。

Catalog(外部) CatalogProduct title / 图集 类目 / 文案 currentPrice(会变) 防腐层 ACL 裁剪 改名对齐 价格冻结 Sales(内部) OrderLineSnapshot productId nameSnapshot unitPrice(冻结) 胖对象 干净快照 外部概念到 ACL 为止,绝不越界进入 Sales 领域代码
图 3.3防腐层把 Catalog 的胖 Product 翻译成 Sales 的下单快照。注意:朱红那一侧是 Sales 主动写的翻译——外部模型的字段、变价、结构变化全部被挡在 ACL 左边,Sales 的核心语言保持纯净。

与下一节的关系:到这里,五个上下文的内部边界和它们之间的翻译都立住了。但谁来维护这些边界?上下文边界划在哪,最终和团队怎么分有关——这是团队拓扑要回答的。

3.6团队拓扑 Team Topologies:把康威定律反过来用

流对齐团队(stream-aligned team)大致对应一个限界上下文:与其让组织结构无意识地塑造混乱的边界,不如反过来——先想要什么边界,再据此组织团队。

为什么需要它

康威定律(Conway's Law)说:系统的结构会复刻造它的组织的沟通结构。如果三个团队共管一个「商品」模型,这个模型迟早被三方需求撕成一个谁都不满意的拼盘——上下文边界会沿着团队的沟通裂缝走,而不是沿着语言一致性走。与其被动接受这个结果,不如主动设计。

底层机制(比定义深一层):《团队拓扑 Team Topologies》提出「逆康威调度(inverse Conway maneuver)」——既然组织结构会决定系统边界,那就先设计想要的系统边界,再倒推该如何组织团队。落到云市:让一个流对齐团队完整拥有一个限界上下文(如「下单团队」独占 Sales 的语言、模型、代码和部署),团队边界和上下文边界对齐,团队内部沟通顺畅、跨团队靠明确的上下文映射关系协作。这样康威定律就从「无意识地破坏边界」变成「有意识地强化边界」——团队的自然沟通模式,恰好长出你想要的那条语义边界。

这条线也解释了一个常见的组织级失败模式:当一个「商品」模型由三个团队共同维护,每个团队都只是它的部分股东,没人对整体语言负责,模型就会持续向「谁都能塞字段、谁都不敢删字段」退化。把模型的所有权收拢到单一团队、并让团队边界与上下文边界一致,是从组织层面守住语义边界的根本手段——3.5 节的防腐层守的是代码缝隙,团队拓扑守的是组织缝隙。

想一想

云市的 Sales(核心域,状态机复杂、改动频繁)和 Payment(与外部支付渠道对接)目前由同一个团队维护,「商品」「客户」两套语言混在一个工程里。从团队拓扑角度看,这个安排会在语言上埋下什么隐患?拆成两个流对齐团队能改善什么?

先答,再展开

隐患:一个团队同时握着 Sales 和 Payment 两套语言,「客户」在它脑子里随时在 Buyer 和 Payer 之间漂移,下单逻辑和支付逻辑容易互相渗透字段、共享本不该共享的类,两个上下文的边界被团队内部的「图省事复用」悄悄抹平。拆成两个流对齐团队后:每个团队只对一套语言负责,Sales 团队眼里「客户」永远是 Buyer,Payment 团队眼里永远是 Payer,跨团队协作被迫走明确的上下文映射(如客户-供应商或防腐层)而非偷偷共享类。团队边界与上下文边界对齐,语义边界就有了组织上的守护者。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. 一词多义为什么会逼出限界上下文?说清「对话能消歧、代码不能」这一层,再用云市的「商品」举一例。
  2. 「限界上下文 = 微服务」错在哪?给出一个「单体里也成立」的具体安排。
  3. 防腐层具体解决什么问题?以 Sales 调 Catalog 为例,说出它做的三件翻译动作里至少两件。
  4. 从九种关系里挑两个辨析:遵奉者 Conformist 和 防腐层 ACL,下游的语言纯净度有何不同?各自代价是什么?
答案(先做完再展开)
  1. 对话里听者用语境自动消歧,「商品」是图文还是数量靠场景秒懂;代码没有这个听者,一个被所有人共享的 Product 类必须同时满足所有含义,字段越堆越矛盾。云市的「商品」在 Catalog 是胖展示对象、在 Sales 是价格冻结的下单快照、在 Inventory 是 Sku+数量——四个不同的类。要让每个词在一处只有一个含义,就得画一条边界把语言框起来,这条边界就是限界上下文。
  2. 错在把逻辑边界(模型/语言)当成物理边界(独立部署)。限界上下文先于微服务存在。单体里也成立的安排:用模块/包把 Catalog、Sales、Inventory 隔成三个上下文,各有各的 Product 类、互不 import 对方领域对象,仅通过应用层接口或进程内事件协作——这就是模块化单体,没有任何微服务。
  3. 它挡住外部模型腐蚀本上下文的语言。Sales 调 Catalog 时,ACL 做:裁剪(丢掉图集、类目、营销文案等 Sales 不用的字段)、改名对齐(title → nameSnapshot,落进 Sales 的语言)、语义冻结(把会变的 currentPrice 翻译成下单时刻冻结的 unitPrice)。没有它,Sales 的核心模型会被 Catalog 的字段和变价污染,「单价快照」不变量立不住。
  4. 遵奉者:下游直接沿用上游模型、不做翻译,语言纯净度被牺牲,下游被上游同化、上游一变下游全变;代价低在省了翻译代码,高在失去自己语言的独立性。防腐层:下游写一层翻译把外部模型挡在外面,语言保持纯净、能独立演化;代价是多一层翻译代码要写要维护、上游演化时翻译规则要跟着改。核心域(如 Sales)几乎都应选防腐层而非遵奉者。
进阶挑战 · 刚好够不着

两个团队共享一个「用户」模型,挑一种映射关系止血

云市的 Sales 团队和营销团队当前共用同一个 User 类。Sales 眼里它是 Buyer(收货地址、下单历史),营销眼里它是 Member(会员等级、积分、活动资格)。痛点已经出现:营销想加「积分冻结」状态机,每次改 User 都要 Sales 团队一起回归;Sales 想给下单加风控字段,又怕碰坏营销的积分逻辑;两边的需求在同一个类里持续打架,发布互相阻塞。请先把这两个上下文拆开(各自有自己的 Buyer / Member 类),再从九种关系里挑一种来定义它们之间的协作,并说明为什么选它、代价是什么。

提示(卡住再展开)

第一步是承认一词多义:Buyer 和 Member 本就是两个类,先各自独立。第二步看协作形态——营销要用到 Sales 里的某些用户事实(如是否下过单),但两边语言不同、且都不想被对方同化。防腐层 ACL 是稳妥解:营销在自己一侧写一层翻译,把 Sales 暴露的用户信息翻成 Member 需要的形状,外部概念到 ACL 为止,两边各自的语言都保持纯净,发布也不再互相阻塞。代价是营销要写并维护这层翻译,Sales 模型演化时翻译规则要跟着改。若 Sales 愿意把营销的需求正式排进迭代,也可考虑客户-供应商(营销作为客户、Sales 作为供应商);若两边其实几乎不需要交换数据,各行其道也是合法答案。关键不是标准答案,而是:先拆语言,再显式命名关系,绝不退回共享一个类。