上一篇文章中,我们学习了领域、子域和限界上下文,知道了 DDD 的战略设计首先要解决一个问题:哪些业务应该放在一起,哪些业务应该分开。
不过,边界划分出来只是第一步。
真实项目中,即使大家都在讨论“订单”,产品、开发、测试和财务理解的也可能不是同一个东西。如果这些词没有统一,模型、接口和代码就容易各说各话;边界之间没有明确关系,服务调用也会慢慢变成互相依赖。
所以这一篇我们继续学习战略设计里面的两个概念:
- 通用语言(Ubiquitous Language):解决同一个上下文内部如何统一表达;
- 上下文映射(Context Map):解决不同限界上下文之间如何协作。
一、为什么业务和代码总是对不上
产品说:“用户提交订单后,可以取消订单。”
这句话听起来没有问题,但开发真正开始写代码时,一堆问题就出来了:
- “提交订单”是保存草稿、创建成功,还是已经支付;
- 用户取消和客服关闭是不是同一个动作;
- 取消后需不需要释放库存、退回优惠券;
- 已经支付的订单到底叫取消还是退款。
如果这些问题没有说清楚,代码里面很可能出现下面这种方法:
updateOrderStatus(orderId,4);4是什么意思,为什么能改成4,修改之后还要做什么,只看这行代码完全不知道。
另一个开发可能又写了一个:
closeOrder(orderId);这时候“取消订单”和“关闭订单”到底是不是一回事,也没人能说清楚。
问题的根源不是方法名没起好,而是团队没有先统一业务语言。
二、什么是通用语言
通用语言英文叫 Ubiquitous Language,也有人翻译成统一语言。
通用语言是一套由业务人员和开发人员共同建立,并且在同一个限界上下文中持续使用的业务语言。
也就是说,团队在讨论业务、写需求、画模型、定义接口和编写代码时,应使用同一套含义明确的词。
1. 不是开发自己定义一套术语
通用语言不是开发人员关起门来给类和字段起名字。
它应该来自业务讨论,由产品、业务、开发和测试共同确认。
比如业务上明确区分:
- 用户主动放弃未支付订单,叫作取消订单;
- 超时未支付由系统终止,叫作关闭订单;
- 已支付订单退回资金,叫作退款。
那么代码里面也应该保留这种区别:
order.cancel();order.close();order.refund();而不是全部写成:
order.updateStatus(status);前一种写法能直接表达业务动作,后一种写法只是修改数据。
2. 通用语言只在特定上下文中成立
上一篇文章中我们提到,限界上下文会给模型划定生效范围。
通用语言同样受这个边界约束。同一个词在不同限界上下文中,可以有不同含义。
还是以电商系统为例:
| 限界上下文 | “商品”的含义 | 重点关注的信息 |
|---|---|---|
| 商品上下文 | 可维护和展示的商品资料 | 名称、类目、品牌、详情 |
| 交易上下文 | 用户下单时购买的交易项 | 成交价、购买数量、优惠快照 |
| 库存上下文 | 可以入库、出库和锁定的库存单元 | SKU、可用量、锁定量、仓库 |
它们都可能说“商品”,但是关注的不是同一个模型。
如果为了追求全系统统一,强行让三个上下文共用一个Product类,这个类最后就会同时包含展示、交易、库存等字段,改任何一处都可能影响其他模块。
所以,通用语言追求的是上下文内部一致,不是整个系统只有一套万能模型。
这张图强调的不是三个对象应该怎么设计,而是语言和模型都有明确的生效边界。
三、通用语言怎么落到代码里
通用语言不能只停留在会议纪要或者术语表里,它最终要进入代码。
如果开会时说“取消订单”,代码中却叫changeStatus,团队很快还会回到各说各话的状态。
1. 类名要体现业务角色
不要只根据数据库表名机械生成对象。
比如交易上下文中的订单,可以使用Order;支付上下文里记录支付过程的对象,可以使用PaymentOrder。即使它们在数据库里都带有order字样,也不代表它们是同一个业务对象。
2. 方法名要体现业务动作
业务代码里面,优先使用有业务含义的方法:
order.submit();order.cancel();order.confirmPayment();少用看不出意图的名字:
order.update();order.handle();order.process();一个好的业务方法名,应该让我们不看方法实现也能大概知道它在做什么。
3. 状态和规则也要使用业务词汇
下面这种状态虽然能运行,但是阅读成本很高:
privateIntegerstatus;业务允许的情况下,可以把状态表达得更明确:
publicenumOrderStatus{WAITING_FOR_PAYMENT,PAID,CANCELED,CLOSED}需要注意,这里不是说所有字段都必须改成枚举,也不是说用了枚举就是 DDD。
重点是:代码中的概念和业务讨论中的概念要对应得上。
4. 发现歧义时及时更新语言
通用语言不是项目启动时整理一次,以后就不变了。
随着业务深入,我们可能会发现原来的词不够准确。比如最开始只有“退款”,后来业务增加了仅退款、退货退款、部分退款,这时候模型和代码也要跟着调整。
如果一个词在会议中需要反复解释,或者不同人总是给出不同理解,通常就说明这个词还没有定义清楚。
四、什么是上下文映射
限界上下文划分完成后,各个上下文并不会完全隔离。
下单需要查询商品,下单成功需要锁定库存,支付成功需要通知订单,订单完成以后可能还要增加会员积分。
这些协作关系放在一起,就是上下文映射。
上下文映射是对多个限界上下文之间关系、依赖方向和协作方式的明确描述。
它关注的不是某个类怎么写,而是下面这些问题:
- 哪个上下文依赖哪个上下文;
- 谁是上游,谁是下游;
- 双方是否共享模型;
- 外部模型进入当前上下文时要不要转换;
- 上游发生变化时,下游会受到多大影响。
通俗一点讲,限界上下文告诉我们“边界画在哪里”,上下文映射告诉我们“边界之间怎么打交道”。
五、常见的上下文映射关系
上下文映射有不少模式。这里先介绍项目中比较容易遇到的几种,重点理解它们解决什么问题,不需要一上来背所有英文缩写。
1. 客户方—供应方
客户方—供应方也叫 Customer/Supplier。
上游上下文提供能力,下游上下文使用能力。下游对接口有明确诉求,并且可以参与上游接口或发布计划的协商。
比如商品上下文向交易上下文提供商品查询能力。交易上下文是使用方,它可能要求接口稳定返回商品是否可售、当前价格等信息。
这种关系不是简单的“我调你接口”,重点在于:下游需求能够进入上游的协作计划。
2. 防腐层
防腐层也叫 Anticorruption Layer,简称 ACL。
当外部系统的模型和当前上下文差异较大时,我们不直接把外部对象带进领域模型,而是在边界处做一次转换。
例如外部库存系统返回:
{"sku_id":"SKU-1001","available_qty":20,"locked_qty":3}当前交易上下文真正关心的可能只是“本次购买数量是否可用”。这时候可以通过防腐层转换成当前上下文自己的表达:
publicinterfaceInventoryGateway{booleanisAvailable(StringskuId,intquantity);}外部接口字段、协议和异常处理都留在适配实现中,领域代码只依赖自己能理解的能力。
**防腐层的核心价值,是保护当前上下文的模型不被外部模型污染。**至于防腐层具体怎么写,后面的进阶章节会单独展开,这里先理解它在上下文关系中的位置。
3. 开放主机服务和发布语言
开放主机服务叫 Open Host Service,发布语言叫 Published Language。
当一个上下文需要向多个调用方稳定提供能力时,可以设计一套公开、稳定的服务协议,并使用双方都能理解的数据格式进行交互。
常见形式包括 REST API、RPC 接口、消息事件、OpenAPI 文档和明确版本的 DTO。
这里的关键不是用了 HTTP 还是 MQ,而是对外契约要稳定,并且不能直接暴露上下文内部模型。
4. 其他常见关系
| 关系 | 适用场景 | 注意事项 |
|---|---|---|
| 合作关系 | 两个上下文共同开发一项业务 | 一方修改契约时,要同步评估另一方 |
| 共享内核 | 双方共享少量含义完全一致的模型或规则 | 共享范围必须小,并由双方共同维护 |
| 遵奉者 | 下游无法要求上游调整,只能接受其模型和接口 | 接入简单,但下游容易受上游模型影响 |
| 各行其道 | 集成成本大于收益,各自实现更简单 | 不是所有重复都值得抽成共享能力 |
六、用一个电商场景串起来
假设现在有商品、交易、库存、支付和会员五个限界上下文。
它们之间可能存在下面这些关系:
| 上下文关系 | 协作方式 |
|---|---|
| 商品 → 交易 | 商品提供公开查询接口,交易使用稳定 DTO 获取商品信息 |
| 交易 → 库存 | 交易通过防腐层调用库存能力,只保留“锁定库存”等本地业务语义 |
| 支付 → 交易 | 支付成功后发布消息,交易根据契约更新订单支付结果 |
| 交易 → 会员 | 订单完成后通知会员增加成长值,双方通过公开事件格式协作 |
把这些关系画成上下文映射图,大概就是下面这样:
图中的实线表示同步调用,虚线表示异步事件。交易上下文调用库存能力时增加了一层模型转换,避免库存上下文的字段和协议直接进入交易模型。
这里需要注意,上下文映射表达的是业务边界之间的关系,不等于微服务调用拓扑图。
一个限界上下文可以暂时和另一个限界上下文部署在同一个应用里,它们仍然是两个模型边界。反过来,一个微服务如果塞进了多个互相混乱的业务模型,也不能因为已经拆成服务,就认为边界一定合理。
我们可以用下面几个问题检查映射是否清楚:
- 调用关系的上下游是否明确;
- 对外契约由谁维护;
- 上游字段变化会不会直接冲击下游领域模型;
- 外部异常在哪里转换;
- 双方使用的同名概念是否真的含义一致;
- 这段共享或依赖是否真的有必要。
如果这些问题回答不出来,说明边界虽然画了,但上下文之间的关系还没有梳理清楚。
这些模式是描述真实关系的工具,不是必须全部套用的设计检查表。
七、总结
这一篇我们继续学习了 DDD 的战略设计,主要整理了通用语言和上下文映射。
简单总结一下:
- 通用语言用于统一同一个限界上下文内部的业务表达;
- 通用语言要进入需求、模型、接口和代码,不能只停留在会议上;
- 同一个词在不同限界上下文中可以有不同含义;
- 上下文映射用于描述不同限界上下文之间的依赖和协作关系;
- 防腐层可以隔离外部模型,公开服务契约可以稳定上下文之间的协作;
- 上下文映射不等于微服务调用图,更不能代替真实业务边界分析。
到这里,我们已经知道了怎么划分边界、怎么统一边界内的语言,以及边界之间怎么协作。
不过这些结果不能只靠几个人坐在会议室里面拍脑袋决定。下一篇文章,我们继续学习事件风暴,看看怎么让业务、产品、开发和测试一起把业务流程、关键事件和边界梳理出来。