1. 为什么服务拆分总是拆错:战略设计与战术设计的分工边界
1.1 从一次失败的服务拆分说起
先说个真实感受。前几年我参与过一个项目,技术团队花了大力气把原来的单体应用拆成了十几个微服务。拆的时候大家热情很高,按功能模块切、按数据表切、按团队分工切,怎么方便怎么来。结果上线三个月后,服务之间的同步调用链路长得像蜘蛛网,一个用户下单操作背后要经过六个服务的远程调用,改一个需求要联动四个团队一起发版。后来复盘原因其实很扎心——拆分标准是臆想出来的,不是从业务本身长出来的。
这个痛经几乎每家做分布式改造的公司都遇到过。本质在于什么呢?在于很多人把“领域驱动设计”和“怎么画类图、怎么写仓储、怎么分聚合”划等号,一上来就钻进战术设计里调代码结构,却忽略了一个更前置、更决定全局的问题:业务领域到底应该被切成哪些块,块和块之间是一种什么关系,哪块才是公司真正的命根子。这一层,就是战略设计要干的活。
战略设计和战术设计的分工可以这样理解:战略设计回答的是“系统要照着业务的哪张地图来盖房子”,战术设计回答的是“房子内部的每一面墙怎么砌、电路怎么走”。如果地图画错了,砌墙砌得再漂亮也是白搭。因此,战略设计才是整个DDD落地过程中最该花时间、最该让业务专家深度参与的那一步。
1.2 战略设计管什么,战术设计管什么
DDD的开山之作里面其实已经把这个边界讲得很清楚,只是很多人读的时候把重心放在了后半部分的核心构件上。简单梳理一下:
战略设计覆盖的四个核心任务:
- 划分限界上下文(Bounded Context),确定每个上下文的核心职责边界
- 设计上下文映射(Context Mapping),定义上下文之间的协作关系
- 划分子域(Subdomain),区分核心域、支撑域和通用域
- 建立统一语言(Ubiquitous Language),让业务专家和开发团队用同一套词汇沟通
战术设计覆盖的核心任务:
- 聚合(Aggregate)与聚合根的识别与建模
- 实体(Entity)与值对象(Value Object)的区分与设计
- 领域服务(Domain Service)的职责划分
- 仓储(Repository)与工厂(Factory)的实现策略
这里有个非常常见的误区:很多人以为直接做战术设计就算DDD落地了,画一堆聚合根、值对象,自我感觉建模很“领域”。但实际上,如果连限界上下文都是拍脑袋分的,聚合边界就必然会碎一地。因为聚合是在某个特定上下文内部才成立的,一个订单在不同的上下文里,可能一个是“交易订单”,一个是“履约订单”,一个是“对账订单”,同一个名字背后是完全不同的模型。
反过来也有一种极端情况:整个项目组把一个几百万用户的系统当成一个限界上下文来建模,在代码里把所有业务规则揉在一个超级聚合里,结果就是性能瓶颈和改一处崩三处。这两种情况我都见过,根源都是战略设计缺失或做做样子。
所以这篇内容我打算沿着战略设计这条主线拼命往下挖一层,把限界上下文怎么找、上下文映射怎么选、子域怎么分、统一语言怎么建、以及战略设计结果怎么传导给战术设计这些关键动作,一个个拆开讲。每块都会带上我踩过坑之后的修正思路,希望能帮后来的人少走一点弯路。
2. 限界上下文:软件世界里的“国境线”与“方言区”
2.1 限界上下文不是“模块”,而是模型的有效边界
听我一句劝:如果把限界上下文理解成“大模块”或者“子系统”,那你已经跑偏了一半。模块是代码组织层面的概念,限界上下文是模型语义层面的概念。每个限界上下文内部有一套完整的、自洽的领域模型,模型里的每个术语在这个上下文里都有精确的、无歧义的含义。出了这个边界,含义可能就变了。
举个特别好懂的例子。一家公司里,“客户”这个词,销售部门理解的是“有潜客、有成交客户”,客服部门理解的是“正在给我们反馈问题的联系人”,财务部门理解的是“有应收账款和信用额度的对象”。你要是强制让全公司用一套客户模型,结果就是一个臃肿的超级模型,每个部门只用其中一部分字段,还要为其他部门的字段做非空校验和维护。
对,这就是限界上下文要解决的痛点:同一个业务概念在不同业务场景里就是不一样的东西,硬捏在一起只会让模型又大又脆。放到软件系统里,销售上下文、客服上下文、财务上下文,各自维护自己的“客户”模型,通过明确的接口协作,这才是DDD战略设计希望达成的状态。
2.2 识别限界上下文的核心线索:业务能力、业务流程与语言边界
那到底怎么找限界上下文?方法有很多种,我只挑在实际项目中验证过最有效、最适合推广的三条线索。
第一条线索:业务能力。你去看这个组织/系统到底具备哪几类本质性的业务能力。比如某电商公司,它的本质能力包括:商品管理、营销活动、订单交易、库存调度、支付结算、履约配送、售后客服、数据分析。每种能力都有独立的业务目标、独立的规则体系、独立的术语体系。这些能力边界,往往就是限界上下文的天然候选边界。
第二条线索:业务流程中的阶段切换。业务流程通常可以切成几个阶段,每个阶段的参与角色、核心关注点、操作对象都不一样。就拿订单来说:从“购物车结算”切到“支付中”是个阶段切换,“支付完成”到“仓库发货”又是个阶段切换,“签收完成”之后进“售后/对账”又是一个阶段切换。这些阶段切换点通常意味着上下文边界的候选位。因为阶段一变,模型的语义就变,规则就变。
第三条线索也是最本质的一条:语言的边界,也就是术语体系的一致性。去翻翻业务部门的会议纪要、需求文档、产品PRD,你去找哪些词在不同文档里含义明显不同,哪些词在同一个业务域里反复出现且含义完全一致。如果一组术语内部高度自洽、对外有明显歧义,那这就是一个限界上下文的语言边界。
实际操作上,我个人的习惯是把这三条线索结合起来交叉验证:先圈出业务能力清单,再对照核心业务流程的阶段性变化,最后用术语一致性来校验。三条线索重叠度高的区域,基本可以确定是一个独立的限界上下文。
2.3 两个容易混淆的边界判别例子
场景一:一个订单系统要不要拆成交易上下文和履约上下文?
很多人觉得订单就一个,别拆了,拆了还得多做服务间调用。但我们拿线索套一下:交易上下文里的订单关注的是“金额、优惠、支付状态、买家信息”;履约上下文里的订单关注的是“包裹、物流单、发货状态、仓库信息”。这两个上下文对订单的状态机定义都不一样。交易上下文只关心“待支付/已支付/已取消”,履约上下文关心“待发货/已发货/已签收/异常”。你硬把这两套状态机放一个模型里,写代码的时候每个状态转换都要考虑两个领域的约束,改一个字段会影响另外一套逻辑。实际项目中这种拆法后期复杂度不是加法,是乘法。
场景二:用户认证和用户资料,是同一个上下文吗?
很多系统把用户注册登录、用户个人信息管理放在一起叫“用户服务”。从业务本质上来说,认证关心的是“身份凭证、令牌、加密方式、会话有效期”,用户资料关心的是“昵称、头像、收货地址、偏好设置”。这俩确实会在同一张表或者同一个服务里,但从模型语义看差异很大。如果团队规模小、业务简单,放在一起也说得过去;但一旦业务做大了,比如要做多端登录、要做用户画像、要做私域运营,这俩迟早要分家。战略设计不是给你一套“必须怎么分”的死规则,而是给你一套“判断是否该分”的思考框架。
3. 上下文映射:系统之间的“外交关系”如何设计
3.1 七种映射模式速览
限定好了上下文边界,接下来就得确定边界之间的关系——这就是上下文映射。战略设计的经典著作里提出了好几种模式,我只列最重要、在工程里最常见的几种做一张速览表。
| 映射模式 | 关系方向 | 特征 | 常见场景 |
|---|---|---|---|
| 合作关系 | 双向 | 两个上下文协同完成一个用例,需同步发布 | 两个团队共同负责一条核心链路 |
| 共享内核 | 双向 | 共享一份模型或代码库,耦合高 | 两个上下文共享通用基础模型 |
| 防腐层 | 单向 | 下游通过适配层隔离上游模型的污染 | 集成遗留系统或外部第三方系统 |
| 开放主机服务 | 单向 | 上游提供协议化接口,下游按契约集成 | 对外提供稳定API的公共服务 |
| 发布语言 | 单向 | 用一套中立的数据模型交换信息 | 异构系统间数据同步、消息事件 |
| 追随者 | 单向 | 下游完全依赖上游的模型和接口,无自主性 | 强依赖外部系统的场景 |
| 大泥球 | 不推荐 | 混在一起的乱模型 | 需要逐步重构的遗留系统 |
这里有个很重要的认知:映射模式不是选出来的,是被业务关系决定的。你不能为了“技术先进”硬上防腐层,也不能因为“省事”乱用共享内核。每种模式都有它的代价,选型的前提是先看清楚两个上下文之间真实的业务耦合度、团队协作方式、发布节奏差异。
3.2 合作关系、防腐层、开放主机服务:最常用的三种
先说合作关系。这种模式在同一个公司内部的几个上下文之间很常见。比如订单交易和库存调度,用户下单要锁库存,库存释放要通知订单。两个团队哪怕边界划得再清楚,也得高频率同步和协调,发布节奏基本一致。这种关系下,两端都不能自顾自地改接口,必须把联动变更纳入排期。听上去耦合度很高,但只要双方业务目标一致,这个耦合是业务本质决定的,硬拆只会制造更多接口。
再说防腐层,这是我个人认为最值得多加练习的模式。核心价值在于:你不让外部模型的复杂性渗透到你的核心模型里。最常见的场景是接遗留系统。有些老系统的字段含义混乱、接口语义过时、甚至一个接口管十种业务,你的新系统如果直接对着老接口写业务代码,那老系统的大泥球就会顺着接口污染你的新模型。防腐层在中间做翻译和隔离——对外适配老接口,对内输出符合你领域模型语义的对象。这个翻译层可能会损失一些上游的灵活性,但换来的是内部模型的纯净和稳定,从长期看非常划算。
开放主机服务适合需要被多个下游复用的上游上下文。比如支付上下文,下游可能是交易系统、退款系统、对账系统、财务系统。如果每个下游都直接依赖支付上下文的内部模型,那支付上下文每重构一次,所有下游都跟着截屏。正确的做法是支付上下文对外暴露一套稳定的协议化接口——API或者消息事件都行,下游只依赖这套协议,不关心内部实现。这套协议的本质是什么?是上游把“自己内部怎么玩”和“对外提供什么承诺”彻底解耦了。
3.3 一个实际项目里的映射组合案例
讲个具体的案例。我自己参与过一个模拟的分布式订单履约平台改造,涉及以下上下文:
- 交易上下文:承接下单、支付回调、订单状态变更
- 库存上下文:管理仓库、库存台账、库存预占/释放
- 履约上下文:生成发货单、对接物流、回传物流轨迹
- 财务上下文:生成账单、对账、处理发票
我们的映射设计如下:
交易到库存走的是“防腐层 + 开放主机服务”的组合。库存对外暴露“预占库存/释放库存”的开放服务,交易侧通过一个适配层调用,适配层负责把交易侧的订单数据翻译成库存侧需要的预留单数据。这样库存内部的库位策略、批次策略全部变化时,交易侧完全无感。
交易到履行用的不是同步调用,而是发布语言模式——交易上下文发布“订单已支付”的领域事件,履约上下文订阅事件后自己去织发货单。两个上下文之间只通过消息结构通信,这个事件数据结构就是双方约定的“发布语言”。
交易/履约到财务,则用防腐层做隔离。财务需要的不是订单明细,而是具有财务语义的记账凭据。所以交易和履约向财务推送数据时,经过一个转换层重新组织数据,输出的对象是“应收单”而非“订单”。
这套组合下来,当时改造完成后的体会是:上下文之间真正做到了“能异步不同步、能翻译不裸奔”。上线后一个特别显著的改善是,库存策略调整不影响交易代码发布,物流商切换不影响履约之外的任何模块。
4. 子域划分:别把核心域和支撑域做成一样的优先级
4.1 三类子域的判定标准
限界上下文是从模型的语义边界切出来的,子域则是从业务价值和战略重要性角度切出来的。两者视角不同,但结果高度呼应。子域一般分为三类:
核心域(Core Domain):这是公司区别于竞争对手、赖以生存的业务部分,是整个系统的灵魂。核心域值得投入最优秀的人才、最充足的时间、最精细的建模。比如物流公司的核心域是“路由规划与运力调度”,电商平台的核心域是“交易与商品管理”,内容平台的核心域是“推荐与内容理解”。
支撑域(Supporting Domain):这类业务支撑核心业务的正常运行,但本身不构成竞争优势。比如电商的“库存管理”对很多平台来说,属于没有它不行,但做好了也不会因此打败竞争对手。支撑域可以适度外包、复用现成系统、降低建模精细度。
通用域(Generic Domain):市场上已有的成熟解决方案很多,不需要自己投入太多研发力量的领域。比如权限认证、短信发送、文件存储、支付网关对接。这类子域的最佳策略是采购或直接使用成熟的通用系统/服务,把自研精力省下来。
这里有一条判断红线:很多团队把大量人力投入通用域的“自研轮子”,比如做个内部使用的CMS、做个IM工具,结果核心域的迭代反而跟不上业务诉求。这种资源错配,长期看是对公司竞争力的直接消耗。战略设计的作用之一,就是逼着团队把这些优先级摊在桌面上,明明白白地拍板。
4.2 为什么要用投资视角看待子域:资源分配
把子域划分放到投资视角来看会特别清晰。公司的研发资源永远是有限的,每投入一个人日到某个子域,就意味着核心域少了一个人日。如果你把三类子域平均用力,那你实际上是在用做核心域的精力做通用域,再用做通用域的粗放方式处理核心域,两头不讨好。
我见过一个金融业务系统的真实教训:团队把报表系统当成核心系统来打磨,报表模块做得极其精致,各种取数口径、报表模板、权限矩阵,投入三个高级开发搞了大半年。而真正决定放贷风险的核心决策引擎,反而只配了两个初级开发外加一个兼职架构师。后来业务方追着要风控策略调整,核心域响应速度根本跟不上。这就是典型的核心域、支撑域错位。
正确的分配原则是:
- 核心域:最好的人,最深的建模,最高的代码质量,最完善的测试覆盖,最高频的业务对话
- 支撑域:够用就好,保证可靠性,接受一定程度的技术债
- 通用域:优先采购/开源,能不用自研就不用自研
这套投资逻辑确定后,你的技术选型、团队招聘、排期优先级都有了依据。
4.3 子域拆分中常见的认知误区
误区一:把“系统模块”当成“子域”。很多人的软件里有个模块叫“报表模块”,但从子域角度看,报表可能横跨财务、运营、销售三个业务子域,只是展示层把它们聚合在一起。子域判断的出发点永远是业务本质,不是代码模块。
误区二:核心域一旦定了就永远不变。市场一变、商业模式一变,核心域就可能偏移。我参与过的一个出行类项目,早期核心域是“运力调度”,后来公司转型做“增值服务”,核心域就变成了“推荐与营销”。战略设计文档应该定期刷新,至少每半年审视一次。
误区三:核心域必须每个用例都做成“高并发高可用”。核心域里也有低价值的边缘功能,通用域里也会有某个环节特别关键。子域划分给的是整体策略,不能教条地套到每个功能头上。
5. 通用语言从哪里来:事件风暴与业务建模实操
5.1 事件风暴的核心流程
统一语言是战略设计最容易被忽略但最致命的一环。没有统一语言,业务专家说“结单”,技术人员理解成“订单关闭”,互相说的都是中文,但对不齐。建立统一语言最实用的方法我个人首推事件风暴。
事件风暴本质上是一个高互动的工作坊:让业务专家和开发团队围在一面超大的墙前,用便利贴按时间顺序贴出领域里发生的所有“业务事件”。流程大致如下:
第一步:找领域事件(红色便利贴)。事件是已经发生的、不可否认的事实,统一用“过去时”动词短语表达。比如“订单已创建”“支付已成功”“库存已扣减”“退款已发起”。每个业务专家写自己负责环节的事件,大家不讨论对错,先把事件铺满墙。
第二步:按时间轴排序。把写好的事件按业务发生顺序贴出来,从“客户发起下单”到“订单完成/关闭”,形成一条完整的时间线。排序过程中会自然暴露出缺失的事件、含糊的事件、重复的事件,这个讨论过程本身就是在对齐业务认知。
第三步:找触发事件的命令与角色(蓝色和黄色便利贴)。命令是事件发生的原因,比如“提交订单”命令触发了“订单已创建”事件。角色是发起命令的人或系统,比如“顾客”“客服”“定时任务”。这步会让业务逻辑的链条变得更清晰。
第四步:找聚合与读模型(绿色和紫色便利贴)。聚合是“承载一组事件和规则的一致性边界”。在这个环节,你不必立刻精确建模,只需要圈出哪些事件与哪些命令属于同一组业务规则。读模型是查询界面的表达,比如“订单详情页”“库存看板”。
第五步:划边界(用粗线分隔)。根据事件、命令形成的密集程度和语义差异,把墙面切开,每个切块就是一个限界上下文的原型。这一步是事件风暴和战略设计衔接的甜点。
5.2 从事件风暴到限界上下文划分的转化
事件风暴结束后,你会得到一面贴满便利贴的墙。接下来最关键的动作不是“按图施工”,而是做一次归并与裁剪。
归并指的是把强相关的事件/命令/聚合聚拢到一起。聚拢的依据是事件间的因果关系密度:如果两个事件几乎总是前后相继触发,且共享的规则与数据高度重叠,那它们大概率属于同一个上下文。
裁剪指的是把墙切块时那些“看着碍眼”的重叠部分挑出来单独处理。比如“订单已支付”这个事件,交易侧和财务侧可能都会订阅,但它本身不归财务上下文管,只是通过发布语言流过去。
我实际操作的建议是:第一轮事件风暴允许边界画得细一些——宁可多切几个候选上下文,也不要一上来就合并。第二轮再基于业务价值和映射复杂度做收敛。直接一步到位地画边界,容易漏掉真实业务中暗藏的语义差异。
5.3 通用语言的维护是长期活
统一语言绝对不是一个工作坊结束就等于建成了。它是活的词汇表,要在需求评审、代码评审、测试用例、产品文档里持续使用并不断修正。
一个很实用的做法是建立领域词汇表(Glossary),把每个术语放在它所属的限界上下文下面,写清楚定义、区别于其他上下文同名术语的说明、相关事件/命令。这份词汇表由技术负责人和业务代表共同维护,任何一方发现术语对不上,都要及时更新并通知受影响方。
另一个容易忽略的点:通用语言不仅仅指业务术语,还包括对“状态”“规则”的共识。比如订单“取消”和“关闭”有什么区别?父子订单与拆单会导致哪些事件发生?这些不写进词汇表,只靠人传人口述,迟早会在某个周五晚上爆出生产事故。
我个人踩过的坑是:建立词汇表时用了一个内部知识库,写得非常完整,但开发写代码时根本不看。后来我把关键术语映射成代码里的类名、方法名和注释,让代码本身成为词汇表的一部分。比如订单聚合根里的方法叫confirm()而不是updateStatus(2),这个改动带来的沟通成本下降是肉眼可见的。
6. 从战略设计到战术设计的落桥:聚合、实体与仓储是怎么接上的
6.1 战略设计如何指导聚合边界划分
战略设计做完之后,最怕的就是战略归战略、代码归代码,两张皮。要怎么把限界上下文、子域、统一语言真正传导到代码层面?核心抓手就是聚合设计。
一个限界上下文内部可以包含多个聚合。聚合的边界划分原则是:同一事务内的强一致性规则必须放在一个聚合里,跨聚合只能做最终一致性。这个原则直接反哺了战略设计划出来的上下文边界。比如“订单”和“订单明细”在同一个上下文里通常是一个聚合,因为订单总额必须与明细强一致。而“订单”和“商品库存”即使业务上紧密相关,也一定是两个聚合,因为扣库存和下单在分布式事务里只能设计成最终一致。
我吃过一个大亏:当时为了性能把订单和库存塞进同一个聚合,直接用数据库本地事务一把锁搞定。业务初期确实又快又稳,后来加了多渠道库存、预售、赠品等多种库存占用类型,这个超级聚合开始频繁锁冲突,改一个字段牵扯一堆规则。最后不得不回炉重造,把库存逻辑剥离成独立的上下文。复盘下来,如果当时战略设计阶段就明确了库存是独立上下文,聚合边界自然就守住了。
6.2 “先战略后战术”常见落地顺序
在实际项目里,把战略设计落到战术实现,我建议按下面这个顺序推进,每一步都留有验证反馈的节点:
- 划定限界上下文候选清单:事件风暴输出初版边界,用业务能力和术语一致性校验
- 明确上下文映射矩阵:把每对上下文之间的关系用映射模式标识出来,画出架构愿景图
- 在每个上下文内识别聚合:从业务事件出发,圈定强一致性边界,找出聚合根
- 设计实体与值对象:在聚合内部做实体/值对象的区分,用统一语言的术语命名
- 定义领域服务与仓储接口:领域服务负责跨聚合编排,仓储接口负责持久化抽象
- 用防腐层/开放主机服务落实映射关系:把上一步的映射模式真正落到代码接口上
这套顺序最大的好处是:每个步骤的产物会被下一步直接消费——上下文映射决定了你写防腐层时防的是谁,子域优先级决定了你对聚合建模的精细程度要投入多少,统一语言决定了你的聚合根应该叫什么名、方法应该怎么命名。每一步都不是孤立的。
6.3 落地过程中的典型坑与应对
坑一:在战术设计阶段回头改战略边界,改来改去最后全乱了。应对策略:建立“协议冻结”机制。战略设计阶段输出的上下文边界和映射模式图,进入战术阶段后不能随意改动;如果确实要改,必须走变更评审流程,评估下游影响。没有准入门槛,边界就是一张草稿纸。
坑二:防腐层做成了“转发层”。正宗的防腐层要做模型转换,比如外部系统的“CustInfo”翻译成你领域里的“Customer”。但很多人图省事,直接在防腐层里写“return externalClient.getData()”。这等于没有防腐,大泥球直接渗透进来。判断标准很简单:上游模型里的字段名、术语、结构,如果原封不动出现在你的领域层代码里,防腐层就是摆设。
坑三:事件风暴开完就扔了便利贴,区域记录五花八门。这是个很现实的问题。工作坊结束至少要做三件事:拍下整面墙的照片归档、把每个上下文的事件清单转成文档、把术语列表录入词汇表。不然两周之后没人记得边界是怎么划的,下次建模又开始凭感觉。
坑四:上下文之间的接口设计用了错误的数据结构。比如你想推“发布语言”模式,但消息体不是中立模型,而是直接裸传一个上下文内部实体的序列化结果。一旦对方调整实体结构,所有订阅方都受影响。正确做法是定义专门的事件结构/数据传输对象,只暴露必要字段,名称语义中立。
关于以上这些坑,我自己的体会是:战略设计从图纸到落地的过程,架构师要充当“翻译官”和“守门人”两个角色。翻译官负责把业务语言转成模型语言,守门人负责防止技术细节污染核心模型。这两个角色需要持续投入,不是说画完图就可以撒手了。
最后再分享一个小技巧:每次做战略设计评审的时候,让核心开发用一段话讲清楚“这个限界上下文是什么、它的核心事件有哪些、它跟哪几个上下文有关系、关系是什么模式”。如果讲不利索,说明边界定义或者映射模式还没想透,赶紧回头再去过一遍,别急着进入编码阶段。