前言

学习DDD首要的是把基础概念弄清楚,因为大部分DDD的书籍都是在不同章节中穿插着其他章节的知识点,为了防止你在我们讨论一些观点的时候你举足无措,所以我们最好是花一些时间快速地说明三个最重要的概念。

不得不说,这的确是一个稍微有些枯燥的过程,但是我建议你可以暂时先快速地过一遍,留一个大致的印象,以后在某个时候有疑惑的时候再回来翻一下就好啦。

学习目标

  • 什么是领域(Domain)

  • 什么是子域(Sub Domain)

  • 什么是限界上下文(Bounded Context)

首先让我们先来看书中的一句话:

在DDD中,一个领域被拆分为若干子域(Sub Domain),领域模型在**限界上下文(Bounded Context)**中完成开发。

这里面涉及到一些名词,往下看的时候就会加深理解。到最后,我们再来重新审视一下这句话。

1. 什么是领域?

广义上看:它是一个组织所做的事情以及其中所包含的一切。

  • 广义上的领域承载的含义比较多,可以表示整个业务系统,也可以表示其中某个系统中的具体某一部分。

  • 在开发中,我们通常只关注系统中的某一部分;一下子考虑太多的东西往往会导致得到一个错综复杂的系统。

因为领域含义太多,所以我们将领域拆分成几个不同的概念以示区别:领域子域核心域。这样的拆分能够让在我们讨论的时候聚焦于某一个具体的方面。

下面先来简单看一下这几个概念:

名词含义举例
子域一个大的领域中,可以拆分成不同的子域,表示领域中的不同方面比如电商系统,可以拆分为库存子系统、支付子系统、订单子系统、发票子系统、ERP系统等不同的业务板块
核心域它是业务的主要促成因素,是最挣钱、最值得关键资源、优先级最高的子域比如电商系统,核心可能在于库存
支撑子域重要,但不是核心的子域比如电商领域的支付系统,它仅专注于支付这一方面
通用子域()作用于整个系统或者系统大多数子域比如电商系统汇总的ERP子系统,里面涵盖了很多跨不同子系统的数据

问题:为什么要划分不同的子域?

  • 一个大而全的系统,是一个巨大的陷阱。随着需求的不断迭代,不同子域的元素慢慢柔和在一起,导致会变得难以维护,牵一发而动全身。

  • 找出系统中隐式的领域模型,当我们专注于某个具体的子域的时候,可以减少外部因素的干扰。

......

2. 什么是限界上下文?

到现在暂时不需要了解得太深。

让我们先“望文生义”:

  • 限界,指的是有边界的;

  • 上下文,指的是语境,即当前讨论的对象所伴随着的一系列数据或其他要素。感兴趣的童鞋可以围观一下这个主题 编程中什么是「Context(上下文)」?

两个词叠加起来,就是一个有边界的语境。这时候就可以稍微理解一些限界上下文的含义了:

  • 限界上下文代表着显式的边界,在任意一个限界上下文中,领域中的字段和行为是没有歧义的;
  • 用程序来比喻的话,你可以理解为它就是拆分子域后所形成的一个子系统;

3. 什么是问题空间和解决方案空间

提问:在一个领域中,核心域有多少个?

这是一个不太好回答的问题,因为答案是不确定。核心域是可能随着市场的变化、业务的变化、系统的迭代而发生变化的。

在思考问题的时候,我们往往是针对于某一个问题来设计解决方案。针对于不同的问题,我们我们所关注的主体不同,其核心域也是不同的。

针对这样的问题,在DDD中提供了两个新的名词:

问题空间(problem space)解决方案空间(solution space)

3.1 问题空间

通常,我们认为,

  • 问题空间领域(注意,这里指的不是子域哦)的一部分,对一个问题空间的开发将产生一个新的核心域。注意理解这句话,问题空间如果仅仅是领域的一部分,这意味着领域的概念是远远大于问题空间本身的;
  • 问题空间的评估要考虑已有的子域额外所需的子域。所以,问题空间=核心域+其他子域
  • 我们采用聚焦问题的原则来思考问题,否则就会陷入和不用DDD一样的大泥潭中。那么,我们在绘制领域模型图的时候,针对于某一个具体的问题空间来绘制,就算是同一个领域,不同的问题空间,其核心域和子域也可能是不同的

3.2 解决方案空间

书本的原话是这样的:解决方案空间包括一个或多个限界上下文....限界上下文是一个特定的解决方案,它通过软件的方式来实现解决方案

我们思考一下这句话。

  • 先看第二句:“限界上下文是一个特定的解决方案”。既然限界上下文是一种解决方案,那么限界上下文就不仅仅是一个可运行的软件而已,解决方案还应该包含了对具体的业务规则的实现、对于名词的定义等其他复杂要素。

  • 再看最后一句:“它通过软件的方式来实现解决方案”。意思是说限界上下文是一个用软件的方式来解决问题,这里限定了DDD的范围,DDD是一种软件的设计方法,而不是一个全面的解决方案。就比如:放弃这一块业务,这显然就是另外的一个解决方案而不在DDD的讨论范围内;另外,如果说限界上下文是一种软件的实现方案,也就是说,我们是在限界上下文中用代码实现了子域的逻辑。

  • 最后来第一句:“解决方案空间包括一个或多个限界上下文”。意思是说,解决方案空间可能会通过多个限界上下文之间的协作来解决问题空间所面临的问题。

这时候,对限界上下文的理解似乎又迈进了一步。

书本接着说: 我们希望将子域一对一地应用到限界上下文中,这样相当于显式地将领域模型分离到不同的业务板块中,此时,问题空间和解决方案空间被融合到了一起

让我们试着理解上面这句话。

  • 既然问题空间只是领域的一部分,而当领域被拆分成不同子域时,如果不出意外,问题空间会跟随着子域的拆分,被拆分为不同的子问题子空间;。
  • 上面有说过,我们在限界上下文实现了子域的逻辑,如果实现的这一部分只是一个子域,那也就意味着:问题空间和解决方案空间被融合到了一起。试想一下,如果在一个限界上下文汇总柔和了不同的子域,那虽然做了子域上的划分,但是在实现上却仍然是一个大的单体架构,这其实是跟DDD聚焦的原则是背离的。

所以,子域应该是同限界上下文一一对应的

4. 什么是限界上下文

从上面看下来,我们已经能够找到一些关于限界上下文的特征:

  • 限界上下文是显式的、是充满语义的。 领域模型存在这个边界之中,在边界之中,模型不会有歧义,包括模型的属性、操作方法;
  • 限界上下文不仅仅包含了模型。 它是问题解决空间一种软件解决方案,它可能还包含了一个系统、一个应用程序、一种业务服务、数据字典等与实现这个业务相关的一系列复杂组件。就比如说,**数据库模型设计、用户界面(User Interface)**等,也应该是限界上下文中的一部分;

举一个书上的例子:

限界上下文含义例子
订单上下文订单(Order),表示用户下的订单。订单有订单的所有元素,比如:订单号、订单类型、下单时间、商品号、数量、金额、减免信息等各种字段。
支付上下文订单(Order),只是支付单中的一个属性。订单中的属性可能仅有:订单号、订单类型两个字段。因为支付上下文根本不关心其他字段。

在上述的例子中,两个不同上下文中的订单实体,虽然是同名,但是其内容却天差地别。

  • 如果我们两个上下文中都应用同一个Order实体。那么相当于支付上下文就要被迫接受很多跟它没有任何关系的属性;并且如果订单上下文要修改Order的属性时候,它根本不敢随便修改,因为改了以后可能会导致其他上下文的报错;

一个问题

那么,不同上下文的实体有必要通过命名上做区分吗?比如,订单上下文叫做Order,而支付上下文叫做PaymentOrder。

的确我们可以这么做,但是似乎有些没有必要,因为限界上下文是显式的,在不同上下文中的Order就已经表示不同的概念。如果我们想要通过加上限定的修饰词来区分不同的上下文的领域模型,那么这往往意味着你没有正确地将子域进行分离,或者是脱离了上下文去讨论模型。这都是不太妥当的。

当然,如果团队中达成了这样的共识,也不是不可行的,这仅仅是一种规范罢了。

5. 限界上下文划分的坏味道

一个限界上下文的大小是因为业务而异和因人而异的。如何划分限界上下文是一个难题。这里我们暂时不去讨论如何如何划分。我们先看讨论一下,大小不正确的上下文存在可能存在哪些坏味道。

5.1 引入了领域之外的概念

核心领域之外的概念不应该出现在限界上下文中。外部概念的侵入,会导致你的限界上下文中被迫考虑领域外因素的影响。而这些影响应该被隔离在限界上下文的防腐层才对。这些外部的概念只会让你的子域的核心逻辑变成“小泥潭”。

5.2 领域的概念没有完全引入

模型应该要能够展示一个子域的完整的模样,如果你在做某个子域模型核心逻辑的时候发现 “这也缺,那也缺”,那很可能是因为缺少了一些必备的模型概念。有一种很常见的场景会导致这个问题,就是当我们根据开发任务来拆分限界上下文,这种情况下,拆分出来的限界上下文一般会比较小,不能包含一个模型应该有的全部概念,这种情况下,大部分需要的数据都会严重依赖于外部上下文的交互来获得。

5.3 模型中概念的设计不够抽象

如果你发现在你的子域的逻辑中,很多逻辑都依赖于具体的某一个实体模型,这个实体会导致复杂的逻辑,但是缺它又不行。这时候就要考虑一下,是不是对这个实体的抽象不够到位。

作者在书中举了一个例子。在系统用户权限方面,如果是从用户的角度来做权限的控制,你会发现权限的管理变得极度困难。这就是没有能够正确对业务做抽象,一个功能有没有权限访问,是对用户的分类(也就是角色)来判断的,而不是具体的用户。不够抽象的逻辑会导致业务逻辑变得复杂。

6. 限界上下文的集成

一个限界上下文不是孤立的,它总得同其他上下文进行交互,这种交互称之为集成

我们通常会通过图形的方式来展示不同系统的之间的关系,限界上下文也是如此。我们来尝试绘制一个限界上下文。

6.1 一个抽象的限界上下文图

在DDD中,我们绘制一个限界上下文比较简单,只要遵循一些简单的原则即可。来看一下书本中给到出限界上下文图的示例。

一个抽象的业务领域

从这幅图,我们来学习一些图形元素:

先总结一下图中有哪些元素(外星语):

  • 一堆圈圈。实线圈圈、虚线圈圈。
  • 连接线。都是实线。
  • 一些描述圈圈文字。

下面,我们对上述的元素进行翻译:

  • 最外层的实线:外部的实线圈圈,表示一个领域,即我们当前所关注的领域。
  • 领域中的虚线:表示划分的子域。
  • 领域中的实线:内部实线围成的圈圈,表示限界上下文,即一个子域对应的系统或者服务。
  • 圈圈之间的实线连接线:表示这两个元素之间存在集成关系。

将外星文翻译过后,我们可能会有一些疑惑。

问题
  • 为什么图里面子域比限界上下文大?

    限界上下文是对于一个问题子空间中软件形式的解决方案,也就是一个子域的一种实现;可是子域还包含了问题本身,以及其他形式的实现等因素,所以子域必然是大于限界上下文的。

  • 为什么有一个子域里面存在两个限界上下文?

    理想情况下,子域和限界上下文是一一对应的;但是实际情况是复杂的,举个例子:如果在一个子域中,自由拥有一个限界上下文的同时,还依赖于另外一个外部系统提供的服务,那么此时就会存在一个子域包含了多个限界上下文的情况。

  • 圈圈之间为什么会存在交集?

    这不正是血淋淋的现实?不同的系统中总会存在一些相互交错的地方,举两个例子:子域的划分不合理,导致业务存在重叠,这时候也会存在圈圈出现交集;或者子域虽然梳理清晰,但限界上下文在代码实现的时候存在了重叠,比如两个不同的子系统交互时,使用了同一个DTO对象。

  • 其他的隐式含义(important!)?

    有一个比较容易忽略掉的地方。你有没有留意到,子域之间的关系是怎样表现的?图中是通过虚线圈圈的交界表现的:如果两个子域是挨在一块的,即是二者存在一定的关联关系。比如:图中的两个支撑子域同核心域交界,说明这两个子域是给核心域提供业务支撑作用的;通用子域同其他三个子域都存在交界,即其他子域都使用到了通用子域。

6.2 限界上下文图形的绘制案例

直接拿我们现有的统一计费的项目来练习一下。

6.2.1 第一版草图

刚拿到需求的时候,就知道是做一个系统系统,为业务系统提供一系列计费服务。根据这些信息可以绘制出第一版草图。

截屏2022-11-23 01.21.51

这时的图形很简单,我们知道它就是两个系统之间存在集成关系。但这样的草图是不足以指导我们完成开发工作的。我们需要对需求作进一步的了解。

6.2.1 第二版草图

随着同业主沟通,需求开始扩张。目前梳理出需要实现以下几个功能:

模块描述
计费系统名就是以“计费”命名的,它作为核心域,没意外。为其他业务系统提供计费服务。
对账重要模块。
结算重要模块。
发票重要模块。为业务系统提供数据开票服务。

根据这一版的需求,我们绘制出了一个更详细的限界上下文

截屏2022-11-23 01.42.32

在这一版图形里,我们将子域、限界上下文、集成关系都包含在内。

  • 统一计费核心功能在于计费,所以计费子域应为核心域
  • 结算、发票都很重要,但是跟核心域(计费)暂时没有什么交集,但是能够提高系统自身的服务价值,所以这几个子域为支撑子域。我认为支撑子域并不意味着为核心域提供支撑,他们可能只是承担了业务目标中重要相对而独立的工作;
  • 对账提供了一些基础数据给其他子域,所以其为支撑子域
  • 不同子域通过各自的限界上下文同业务系统进行集成

问题?

这一版的图其实看起来有不少问题,来看看我们的坑是怎么踩的:

  • 为什么对账子域是通用子域?看起来似乎对账跟其他子域应该不存在太多关联。

    事情是这样的演化而来的。因为这一版的需求是一点一点增加和变更的。

    • 一开始,其实只存在对账、数据报表两个模块。所以当时订单的同步和对账是放到一起去做的,因为只有对账依赖于订单数据;
    • 再后来,数据报表的需求被砍掉,另外需要新增加计费、开票的功能,但是上一步的对账和订单已经耦合在一起了,并且对账业务比较复杂,所以很难拆分开。而新增的计费功能也同订单无关,所以没有抽离;
    • 最后,又增加了结算的需求。结算依赖于订单中的业务类型等字段。这时候发现已经有多个模块会依赖于订单数据。
    • 然鹅,留给开发的时间仅仅只有一个月,已经来不及了,只能硬着头发上了。
    • 这时候导致的最大问题是:代码跟实际业务不符,然后最后系统的结果是正确的,但是从代码上看,对账服务承担了它不应该承担的职能。一旦代码和实际的逻辑不符,业务人员就很难参与到模型的讨论中来,因为没有人能够记得住所有代码是如何实现的。
  • 为什么整张图看上去很乱?

    杂乱的主要原因是因为不同的子域存在交集。这是因为一开始我们是在单体架构的基础上开发的,开发的时候发现其他模块有一些现成的DTO,直接就拿来用了,故存在一定程度的耦合。

6.2.3 第三版草图

在这一版中,根据上一版发现的问题。我们首先重新梳理了业务需求,发现像是业务类型、收费项目、订单等数据几乎都要为其他领域所用,所以单独抽象出一个基础数据子域。下面来看一下新的需求描述:

模块描述
计费核心域。为业务系统提供计费服务。
对账支撑子域。
结算支撑子域。
发票支撑子域。为业务系统提供数据开票服务。
基础数据通用子域。为业务系统提供数据同步服务。

这时候,新的草图如下所示:

截屏2022-11-23 01.25.33

这一版的草图看起来清晰了不少,这一阶段我们主要任务有2个:

  • 从单体架构走向微服务架构,让不同服务之间作物理上隔离,只在接口上耦合,每一个模块能够单独地进行开发、测试和部署;这样就从根源上杜绝了不同子域存在交集的问题。
  • 从对账子域中抽离出基础数据子域。让对账独立于订单。通过几次需求的迭代我们发现,后续可能会存在越来越多的要同步的数据,这种简单而繁琐的逻辑不应该耦合到任何一个服务当中,所以基础数据的同步和维护应该独立为一个单独的通用子域。
6.2.4 总结
  • 需求不清晰和向时间妥协是导致架构走向崩塌的主要因素;
  • 随着需求的清晰,需要在架构上及时调整。重构做得越晚,后续维护的代价越大;
  • 要弄清楚问题空间,理清核心域、通用子域、支撑子域及各子域之间的联系;通过极简的图形,可以帮助我们尽早发现集成的坏味道。
  • 演进式架构。围绕着图形讨论和沟通问题,同时不断更新代码,确保业务和代码的实现是一致的;