文章目录


前言

  DDD架构思想虽然在2004就被提出但却一直并未受到重视,本身也因为这种架构思想自身的抽象性,凭空的论证让人难以理解。在代码层面缺乏足够约束的情况下,导致 DDD 在实际应用中上手门槛很高,甚至可以说绝大部分人都对 DDD 的理解有所偏差。想要落地实现十分困难。同时市面上也没有很多最佳实践能让人们作为参考,但是随着微服务的兴起DDD领域驱动设计模式开始受到越来越多人的重视,并逐渐被青睐。因为它解决了传统开发模式中存在的许多痛点,一直以来我们大多数甚至说百分之90的环境下都直接使用了MVC 的四层应用架构(UI、Business、Data Access、Database)并已经完全固定死了自己所熟悉的那一套开发流程经常采用表驱动设计的思想来进行开发。但是在当今所有的东西都能被称之为“服务”的时代传统的开发模式已经越来越不适用,它的缺点也越来越被放大化软件退化带来的问题更加凸显。这套系列文章我将跟大家一起学习实践DDD并在一个大型项目中实施落地。学习指导信息主要来源于以下技术分享。第一章节主要偏向于概念层次,后续我会将我的一些实际落地案例拿出与大家分享。

去哪网技术大本营:https://space.bilibili.com/1281381784
领域驱动设计峰会2020:https://www.bilibili.com/video/BV14A411s77v?from=search&seid=1212689119340987516
美团技术团队:https://tech.meituan.com/2017/12/22/ddd-in-practice.html
vivo互联网技术:https://my.oschina.net/vivotech/blog/3171589
爱奇艺DDD实践:https://zhuanlan.zhihu.com/p/342826364
书籍-实现领域驱动设计: https://item.jd.com/68288252471.html


一、DDD的优势

  在学习一项技术的时候我们首先要明确学习它的目的是什么,能带给我们什么价值。业务初期,我们的功能大都非常简单,普通的CRUD就能满足,此时系统是清晰的也算是代码质量最高的阶段。随着项目迭代的不断演化,业务逻辑变得越来越复杂,我们的系统也越来越冗杂。模块彼此关联,谁都很难说清模块的具体功能意图是啥。修改一个功能时,往往光回溯该功能需要的修改点就需要很长时间,更别提修改带来的不可预知的影响面。借助DDD可以改变开发者对业务领域的思考方式,要求开发者花费大量的时间和精力来仔细思考业务领域,研究概念和术语,并且和领域专家交流以发现,捕捉和改进通用语言,甚至发现模型乃至系统架构层面的不合理之处。当然有可能你的团队中并没有相关业务的专家,那么此时你自己必须成为业务专家。一些名词不太理解不要紧,后面都会有详细的阐释。下面是爱奇艺技术团队在使用DDD后带来的好处。我们可以直观的看到DDD的优势所在。

会员业务部门在打赏业务进行了DDD实践后,效率有显著提升:

  • 新需求接入开发成本节约20%;
  • 更换底层中间件开发成本节约20%;
  • 项目熟悉成本节约30%(对DDD有基本了解为前提);
  • 单测开发成本指数级降低;
  • 上线风险、成本降低。

下面是vivo技术团队对DDD业务价值的概括:

  • 你获得了一个非常有用的领域模型;

  • 你的业务得到了更准确的定义和理解;

  • 领域专家可以为软件设计做出贡献;

  • 更好的用户体验;

  • 清晰的模型边界;

  • 更好的企业架构;

  • 敏捷、迭代式和持续建模;

  简单总结下来就是DDD能让我们开发更高效,架构更合理的能力的同时。让团队成员对业务的认识更加清晰,让开发人员不仅仅成为编码的工具更能深入理解业务从而开发出更加符合用户期望的产品。大大加强团队协作效率跟产品产出能力,同时也能更好的适应后续产品升级迭代。

在这里插入图片描述

领域模型的重要性

软件设计的核心在于模拟现实世界,领域驱动设计(DDD)通过建立领域模型来实现这一目标。以下是领域模型的关键点:

  • 领域模型的定义:它是一个抽象概念,界定了特定领域的边界,反映了用户业务需求的本质。- 领域模型的独立性:它专注于业务本身,与技术实现无关,能够涵盖实体和过程概念。- 领域模型的作用:它集中了软件的业务逻辑,提高了软件的可维护性、可理解性和可重用性,帮助开发人员将领域知识转化为软件构造。- 领域模型的交流价值:它作为领域专家、设计人员和开发人员之间的共享知识基础,确保软件设计满足真实需求。- 领域模型的建立过程:需要跨职能团队的积极沟通和协作,以深化对领域的理解,细化和完善模型。- 领域模型的表达方式:除了图形表示外,代码或文字描述也是有效的表达手段。它是软件的核心和最具竞争力的部分,精良的领域模型能够快速响应需求变化。

DDD落地代表

DDD的实践并不局限于大型互联网项目。以下是一些成功案例:

  • 阿里零售通- 京东物流库存仿真- 网易新闻APP- 去哪网机票报价- 哈罗交易中台- 爱奇艺打赏业务 这些案例表明,尽管DDD可能在初期带来学习成本和项目进度的挑战,但其长远效益是显著的。无论是大型还是小型项目,都应根据业务实际情况决定是否采用DDD。爱奇艺的数据强调了DDD在提升项目效率方面的潜力。 DDD不一定要完全按照其规范落地,也可以作为设计指导思想。随着越来越多的公司实践DDD,我们有了更多的研究和学习资源,这为软件开发流程提供了新的方向。 注意:DDD的学习和实践是值得的,它可能成为未来几年内软件开发的一个重要流程。
    在这里插入图片描述
    在软件开发中,领域驱动设计(Domain-Driven Design, DDD)是一种提高设计质量的有效方法。以下是DDD建设流程的详细解析:

DDD建设流程

一、需求分析与领域模型设计

在接到新需求时,传统开发流程可能直接进入编码阶段,但DDD倡导首先进行需求分析和领域模型设计。这一阶段是DDD流程的核心,需要领域专家、设计人员和开发人员共同参与。

  1. 需求分析:深入理解业务需求,识别关键领域概念。2. 领域模型设计:将领域概念转化为可视化的模型,确保所有参与者有共同的理解基础。

二、DDD设计流程的两个主要阶段

  1. 建模阶段: - 使用一种通用语言作为沟通工具,确保所有团队成员能够理解。 - 在沟通过程中识别和定义领域概念。 - 将这些概念设计成领域模型,为后续编码提供蓝图。
  2. 编码阶段
    • 根据领域模型来驱动软件设计。 - 用代码实现领域模型,确保软件设计与业务需求和领域逻辑保持一致。

三、专业术语解析

DDD中包含两个层面的设计:战略设计和战术设计。

  • 战略设计:关注软件的宏观结构和长远规划,如软件的分层架构、核心领域模型等。- 战术设计:关注软件的微观实现,如实体、聚合、服务等的具体实现。 通过遵循DDD的建设流程,可以确保软件设计更加贴近业务需求,提高软件的可维护性和可扩展性。
    在这里插入图片描述

战略设计概述

战略设计,简单来说,就是围绕系统核心及其子系统进行设计的过程。它主要涉及系统的划分、交互方式以及核心术语的定制。这个过程可以概括为:提出问题、寻找解决方案并建立模型。在专业领域,这被称为领域分析与领域建模。具体来说,战略设计包括以下几个步骤:

  1. 从业务视角出发:建立业务领域模型,明确业务边界,创建通用语言,并识别上下文。这是一个从抽象概念到具体实现的转变过程。2. 界限上下文与通用语言:界限上下文的划分和通用语言的识别是战略设计中最基本的两个工具。

战略设计的方法

  • 事件风暴:通过事件风暴,我们采用用例分析和场景分析来拆解业务流程。- 建立领域模型:梳理领域对象之间的关系,如实体、命令、值对象等,并将它们归类形成聚合。- 聚合与界限上下文:聚合帮助我们清晰地划分界限上下文,进而建立领域模型。

战略设计的常规流程

  1. 业务领域模型的建立:从业务视角出发,创建一个模型,明确业务边界。2. 通用语言的创建:建立一个通用语言,以便于团队成员之间的沟通。3. 上下文的识别:识别业务流程中的不同上下文环境。4. 事件风暴的应用:通过事件风暴,分析和拆解业务流程。5. 领域对象的识别与归类:识别领域中的实体、命令、值对象等,并进行归类。6. 聚合的形成:基于领域对象的归类,形成聚合,帮助划分界限上下文。7. 领域模型的建立:最终建立一个清晰的领域模型,为系统设计提供指导。 以上步骤构成了战略设计的常规流程,帮助团队从宏观角度理解和设计系统。
    在这里插入图片描述

在这里插入图片描述
在战略设计的过程中,我们首先需要识别问题空间和解决空间这两个层面。通过对上下文的分析,我们可以将问题划分为不同的领域。领域之下,又细分为多个子域。在领域中,存在众多问题,我们需要找出哪些是核心关注点,哪些是关键实现的功能。这是一个不断深入抽象的过程。例如,在某个特定领域内,我们会与产品团队进行深入讨论,以确定核心问题和功能。

  1. 识别问题空间与解决空间
    • 问题空间:指明需要解决的问题范围。
    • 解决空间:确定可能的解决方案范围。
  2. 上下文分析
    • 通过分析上下文,明确问题和解决方案的界限。
  3. 领域划分
    • 将问题和解决方案划分为不同的领域。
  4. 子域细分
    • 在领域之下,进一步细分为更具体的子域。
  5. 核心问题与功能的识别
    • 识别领域中的核心问题和关键功能。
  6. 深入讨论
    • 与产品团队合作,针对特定领域进行深入讨论,以明确核心问题和功能。
      在这里插入图片描述
        战略设计不管是建立领域模型,还是识别通用语言其根本目的都是让团队成员对业务理解加深并达成共识的一个过程。

在这里插入图片描述

4.1.1通用语言

常规流程:

在这里插入图片描述
在这里插入图片描述

通用语言在领域驱动设计中的重要性

概念解释通用语言是团队成员在事件风暴和交流过程中达成共识的语言,它能够清晰地描述业务规则和含义。这种语言是DDD设计过程中的核心,有助于开发出可读性更强的代码,将业务需求准确转化为代码设计。

出现原因在软件开发中,软件专家和领域专家的合作至关重要,但两者之间存在基础交流的障碍。开发人员习惯于使用面向对象的概念来思考问题,而领域专家则专注于他们的专业知识。这种差异导致在交流过程中需要翻译,但这并不总是有效,有时甚至会导致误解。

领域驱动设计原则领域驱动设计强调使用基于模型的语言,这种语言以模型为核心,要求团队在所有交流中使用一致的语言。这种语言被称为“通用语言”,它在建模过程中推动了软件专家和领域专家之间的沟通。

用途- 统一团队语言:无论团队成员的角色如何,通用语言都是交流的统一工具。- 提炼领域知识:通用语言是需求分析的结果,帮助团队成员就系统目标和功能达成一致。- 领域语言的维护:领域语言由团队专有,负责解释和维护,确保概念的一致性。

价值- 解决沟通障碍:通用语言消除了不同岗位之间的歧义和理解偏差,提升了需求和知识消化的效率。- 促进合作:作为领域专家和技术人员之间的纽带,通用语言确保了业务需求的正确表达。- 提升代码可读性:基于通用语言的代码开发,能够更准确地反映业务需求。

实践建议- 在代码中实现概念名称的统一,减少混淆。- 确保在文档和日常沟通中使用统一的概念,以保持概念的一致性。

结论通用语言是领域驱动设计中不可或缺的一部分,它不仅促进了团队成员之间的有效沟通,还提高了软件设计的质量和效率。

在这里插入图片描述

4.1.2 限界上下文概述

概念限界上下文是领域驱动设计(DDD)中的一个核心概念,它定义了一个特定的语义和语境边界。在这个边界内,通用语言和领域对象具有明确且一致的含义,确保没有歧义。限界上下文通常对应一个子系统,它既是语言的边界也是模型的边界,代表了一个问题空间的界定。值得注意的是,一个限界上下文可能跨越多个子域。

动作限界上下文的划分是为了创建一个清晰的上下文环境,这是分解大型复杂模型的关键步骤。它作为微服务架构设计的主要依据,尽管微服务的划分还可以基于其他因素。通过将限界上下文中的领域模型映射到相应的微服务,我们实现了从问题域到软件解决方案的转换。

作用限界上下文在软件设计中扮演着至关重要的角色,它帮助我们:1. 明确领域模型的边界,避免概念混淆。2. 指导微服务的划分,确保服务的独立性和可维护性。3. 促进团队间的有效沟通,通过统一语言减少误解。

通过上述概述,我们可以更深入地理解限界上下文在领域驱动设计和微服务架构中的重要性和应用。
在这里插入图片描述

在这里插入图片描述
界限上下文在领域模型中扮演着至关重要的角色,它帮助我们精确地定义和表达领域内所有对象的具体含义。缺失界限上下文可能导致对同一概念的不同理解,从而产生混淆。以’苹果’一词为例,它在不同领域具有不同的含义。在手机领域,‘苹果’通常指代iPhone 13等智能手机;而在水果领域,它则指代红富士苹果等水果。此外,如文中所述,不同阶段的保单对应不同的事件,界限上下文的存在有助于我们明确对象在特定环境下的具体含义。
11

美团实践指导:

  显然我们不应该按技术架构或者开发任务来创建限界上下文,应该按照语义的边界来考虑。我们的实践是,考虑产品所讲的通用语言,从中提取一些术语称之为概念对象,寻找对象之间的联系;或者从需求里提取一些动词,观察动词和对象之间的关系;我们将紧耦合的各自圈在一起,观察他们内在的联系,从而形成对应的界限上下文。形成之后,我们可以尝试用语言来描述下界限上下文的职责,看它是否清晰、准确、简洁和完整。简言之,限界上下文应该从需求出发,按领域划分。前文提到,我们的用户划分为运营和用户。其中,运营对抽奖活动的配置十分复杂但相对低频。用户对这些抽奖活动配置的使用是高频次且无感知的。根据这样的业务特点,我们首先将抽奖平台划分为C端抽奖和M端抽奖管理平台两个子域,让两者完全解耦。

项目结构建议

模块(Module)是DDD中明确提到的一种控制限界上下文的手段,在我们的工程中,一般尽量用一个模块来表示一个领域的限界上下文。

如代码中所示,一般的工程中包的组织方式为{com.公司名.组织架构.业务.上下文.*},这样的组织结构能够明确的将一个上下文限定在包的内部。

1
import com.company.team.bussiness.lottery.*;//抽奖上下文 import com.company.team.bussiness.riskcontrol.*;//风控上下文 import com.company.team.bussiness.counter.*;//计数上下文 import com.company.team.bussiness.condition.*;//活动准入上下文 import com.company.team.bussiness.stock.*;//库存上下文

对于模块内的组织结构,一般情况下我们是按照领域对象、领域服务、领域资源库、防腐层等组织方式定义的。

1
import com.company.team.bussiness.lottery.domain.valobj.*;//领域对象-值对象 import com.company.team.bussiness.lottery.domain.entity.*;//领域对象-实体 import com.company.team.bussiness.lottery.domain.aggregate.*;//领域对象-聚合根 import com.company.team.bussiness.lottery.service.*;//领域服务 import com.company.team.bussiness.lottery.repo.*;//领域资源库 import com.company.team.bussiness.lottery.facade.*;//领域防腐层
1
package com.company.team.bussiness.lottery.domain.aggregate; import ...; public class DrawLottery { private int lotteryId; //抽奖id private List<AwardPool> awardPools; //奖池列表 //getter & setter public void setLotteryId(int lotteryId) { if(id<=0){ throw new IllegalArgumentException("非法的抽奖id"); } this.lotteryId = lotteryId; } //根据抽奖入参context选择奖池 public AwardPool chooseAwardPool(DrawLotteryContext context) { if(context.getMtCityInfo()!=null) { return chooseAwardPoolByCityInfo(awardPools, context.getMtCityInfo()); } else { return chooseAwardPoolByScore(awardPools, context.getGameScore()); } } //根据抽奖所在城市选择奖池 private AwardPool chooseAwardPoolByCityInfo(List<AwardPool> awardPools, MtCifyInfo cityInfo) { for(AwardPool awardPool: awardPools) { if(awardPool.matchedCity(cityInfo.getCityId())) { return awardPool; } } return null; } //根据抽奖活动得分选择奖池 private AwardPool chooseAwardPoolByScore(List<AwardPool> awardPools, int gameScore) {...} }

团队工作与领域划分

1. 界限上下文与源码仓库一个团队应该在一个界限上下文中工作。每个界限上下文应拥有一个独立的源码仓库。采用与分离通用语言相同的方式,干净地将不同界限上下文的源码和数据库模式隔离开。同时,将同一界限上下文中的验收测试、单元测试与主要源码存放在一起。

2. 领域与子域### 2.1 领域定义领域在业务场景下指的是业务边界,同时它也是指定范围内待解决的业务问题。子域是更小、更细化的领域。通过DDD的分治思想,解决小领域问题,最终形成大领域的解决方案。

2.2 子域细分以打车领域为例,细分为司乘域、交易域、结算域、评价域和用户增长域。交易域进一步细分为形成域、匹配域、订单域和退款域。解决这些子域问题,并通过串联这些能力,自然解决打车领域的大问题。

3. 核心域与支撑域### 3.1 核心域核心域是组织中最重要的项目,需要进行战略投资和精心打磨。例如电商系统中的订单与商品,它们是组织与竞争对手区别的关键。

3.2 通用子域与支撑子域- 通用子域:如视频点播、评论等,每个系统都能用到,可以设置为通用域。- 支撑子域:如调用第三方支付、银行等,提倡“定制开发”,因为现成解决方案可能不理想或成本过高。

4. 上下文映射不属于核心域的概念将被迁移到其他界限上下文中。核心域必须与其他界限上下文进行集成,这种集成关系称为上下文映射。分类包括合作关系、共享内核、客户供应商、跟随者和防腐层。

4.1 跟随者关系跟随者关系存在于上游团队和下游团队之间。下游团队顺应上游模型,例如与一个庞大复杂的成熟模型集成时,团队可能成为其跟随者。

5. 事件风暴事件风暴是一种协作建模技术,用于探索复杂业务领域。推荐教程链接:DDD堵崔饮践撕Event Storming

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

事件风暴实践指南与思考

1. 外部系统与事件生成

在事件风暴中,某些命令可能直接作用于外部系统,而非聚合。这种情况下,我们可以用粉色便签来表示外部系统。例如: [外部系统] 接收命令 -> 产生事件

2. 问题解决与时间管理

在事件风暴过程中,如果遇到问题,应避免长时间讨论。可以设定一个时间盒(time box),如5分钟。如果规定时间内未达成共识,使用红色便签标记问题,留待后续讨论。 [问题] 设定时间盒 -> 未达成共识 -> [红色便签] 标记问题

3. 限界上下文的划分

事件风暴是划分系统内限界上下文的有效手段。主要方法是寻找连接不同业务模块的关键事件。例如: [关键事件] 连接不同业务模块 -> 划分限界上下文

4. 事件风暴后的思考

4.1 聚合模型的价值

事件风暴完成后,聚合模型成为显著成果。但聚合模型更多是概念层面的模型,不适合直接用于数据或代码设计。其价值在于帮助开发人员挖掘领域实体概念,澄清实体间关系。

4.2 统一语言的重要性

事件风暴有助于统一团队对领域知识、领域对象及其关系的理解,这是非常有价值的。

4.3 需求文档的必要性

需求文档应在事件风暴前准备,至少是初版。这有助于避免现场回忆业务规则,减少时间消耗和遗漏。

4.4 业务流程与规则的梳理

不必纠结于现场列出所有业务规则与流程,事件风暴更多是达成领域知识认知的一致性。

4.5 领域驱动设计的实践

领域驱动设计不是一蹴而就的,需要团队对领域知识有深入理解,避免系统耦合,进行快速迭代。

5. 分析问题空间

在分析问题空间时,应关注以下几点:

  • 外部系统的作用- 时间管理与问题解决策略- 限界上下文的识别与划分- 事件风暴后的深入思考与需求文档的准备 通过这些步骤,可以更有效地进行事件风暴,促进团队协作,提高系统设计的质量和效率。

在这里插入图片描述

4.2 战术设计

用一句简单的话总结就是:指导程序员如何一面向对象的思想来设计类和属性等。战术设计最常用的工具便是聚合。

在这里插入图片描述

4.2.0 基础架构知识

在这里插入图片描述
在领域驱动设计(DDD)的开发过程中,我们应将注意力从数据层面转移到领域概念上。首要任务是识别和定义具有行为的领域概念,避免将数据模型直接映射到对象模型,这可以防止领域模型实体充斥着大量的getter和setter方法。 实体定义: 实体是DDD中的一个核心概念,它代表领域中的一个具有唯一标识和生命周期的对象。根据《领域驱动设计》一书中的描述,实体具有以下特征:

  1. 唯一性:每个实体都有一个唯一的标识符,即使两个实体的属性完全相同,只要标识符不同,它们就是不同的实体。
  2. 持续存在:实体在业务过程中持续存在,不会因为属性值的改变而消失或被替换。
  3. 行为:实体具有行为,这些行为是领域逻辑的一部分,反映了实体在领域中的活动。
  4. 属性:实体的属性是其状态的一部分,但属性的变化不一定导致实体的替换,而是状态的演变。 贫血模型与充血模型: 在DDD实践中,有两种常见的模型设计方式:贫血模型和充血模型。贫血模型中,业务逻辑通常集中在服务层,而实体类则主要负责数据的存储和检索,这可能导致实体类变得被动和无行为。相对地,充血模型则将业务逻辑封装在实体内部,使实体更加活跃和具有行为性。这两种模型的选择取决于具体的业务需求和设计偏好。 在实际开发中,应根据领域特性和业务逻辑的复杂性来决定使用哪种模型。选择适当的模型有助于提高代码的可读性、可维护性和可扩展性。
1
一些对象主要不是由它们的属性定义的。它们实际上表示了一条“标识线”,这条线跨越时间,而且常常经历多种不同的表示。

在软件设计中,引入实体领域概念是为了区分不同对象或考虑对象的个性特征。实体是具有唯一标识且能在长时间内持续变化的唯一事物。对实体的多次修改可能导致其状态大不相同,但它们仍然保持相同的身份标识。例如,电商平台上的用户就是实体,需要区别并持续关注他们的行为。实体具有生命周期,其形式和内容可能发生根本改变,但必须保持内在连续性,即全局唯一的ID。实体的类定义、职责、属性和关联应由其标识决定,而非其属性。即使对于生命周期简单或不发生根本变化的实体,也可以将其视为实体,以获得更清晰的模型和更健壮的实现。软件系统中的实体可以是任何满足两个条件的事物:一是具有生命周期的连续性;二是其区分不由用户重视的属性决定。实体可以是人、城市、汽车、彩票或银行交易等。 跟踪实体的标识对系统设计至关重要,但为所有对象添加标识会增加系统复杂性、影响性能并可能导致模型混乱。软件设计需与复杂性斗争,仅在必要时进行特殊处理。例如,将收货地址建模为具有唯一标识的实体可能导致不必要的数据冗余和性能问题。此外,实体不应定义过多属性或行为,而应寻找关联,将属性或行为转移到其他关联的实体或值对象上。例如,Customer实体具有地址信息,可以将这些信息转移到Address对象上,以保持Customer对象的清晰结构,便于维护和理解。
在这里插入图片描述

在这里插入图片描述
在设计软件系统时,遵循一定的设计规范是至关重要的,尤其是在实现领域驱动设计(DDD)时。以下是对设计规范的梳理和建议,以确保代码的可维护性和可测试性。

实体设计原则

充血模型实体应采用充血模型,区别于普通的POJO对象,实体除了状态外还包含行为。但需注意,实体中不应依赖注入外部服务,而应通过方法参数引入,以保持实体的独立性和测试的简便性。

高内聚、低耦合实体类应遵循高内聚、低耦合的原则,避免直接依赖外部实体或服务。这有助于减少外部变更对实体的影响,降低系统复杂性。

行为影响范围实体的行为应仅直接影响自身及其子实体,避免对其他实体产生直接副作用,以提高代码的可读性和可预测性。

实体键在确定实体身份时,考虑其在问题域中是否具有唯一标识,如国家名称、社会安全号码等。

值对象设计原则

不可变性值对象(VO)的属性应不可修改,使用final修饰。VO用于表达模型,简化复杂属性的组织。

结构化表达VO应将相似的描述性属性组合封装,如商品属性、人的全名等,以体现结构化和分类。

度量和描述VO常用于度量和描述事务,易于创建、测试、使用、优化和维护。

设计建议- VO应只处理相关属性,避免属性联合表达不了一个整体概念。- VO的构建应通过构造函数一次性完成,方法应无副作用。

特征识别判断一个领域概念是否为VO,考虑以下特征:- 度量或描述领域中的元素。- 可作为常量。- 组合相关属性为概念整体。- 可替换性。- 可与其他VO进行相等性比较。- 无副作用。- 创建后不可变。

构建建议- VO的构建应简洁,避免复杂性。- 使用构造函数一次性构建VO,确保无副作用。

通过上述规范和建议,可以构建出清晰、可维护的领域模型,为系统的长期发展打下坚实基础。
在这里插入图片描述
在不同的业务领域和上下文中,同一个对象可能被视为实体或值对象。以下是几个例子来说明这一点:

  1. 电商系统中的地址: - 在电商例子中,地址通常被视为一个值对象,因为它主要用来描述订单的目的地。 - 但在国家的邮政系统中,地址则表现为一个复杂的实体,由省、城市、邮政区等多个层级组成,每个层级都可以影响邮政编码等属性。
  2. 电力运营公司的地址: - 初始设计中,地址被视为实体,因为它直接关联到电力服务的目的地。 - 进一步抽象后,可以发现电力服务本身是实体,而地址则退化为描述电力服务地点的值对象。
  3. 房屋设计软件中的窗户: - 在房屋设计中,窗户样式可以被视为一个对象,包含高度、宽度等属性,以及修改这些属性的规则。 - 对于墙对象而言,窗户是描述性的值对象,可以被替换而不影响墙的本质。 - 但在素材系统中,窗户作为附属组件,具有自己的实体属性和行为。
  4. 电商场景中的订单聚合: - 在电商场景中,用户购买多件商品时,设计了主订单和子订单。 - 主订单承载买家信息和订单金额等属性,而买家信息本身是一个不需要状态流转的值对象。 - 创建订单时,主订单和子订单形成一个聚合,提供创建清单的能力。 这些例子展示了对象在不同上下文中的多样性和复杂性,强调了在软件设计中考虑上下文和业务域的重要性。
    在这里插入图片描述

如何创建好的聚合?

边界内内容的一致性在设计聚合时,应确保一个事务中只修改一个聚合实例。如果需要在边界内实现强一致性,应考虑将聚合拆分为独立的单元,并采用最终一致性的方式。每个聚合应形成保证事务一致性的边界。

设计小聚合大部分聚合可以只包含根实体,无需包含其他实体。如果需要包含其他实体,可以考虑将其作为值对象处理。

引用其他聚合或实体当聚合之间存在关联时,应通过唯一标识来引用。对于外部上下文中的实体,应引用其唯一标识或构造相应的值对象。如果聚合的创建过程复杂,推荐使用工厂方法来隐藏内部逻辑。

使用最终一致性在更新其他聚合时,可以采用最终一致性的方式。

聚合内部关系与数据库设计聚合内部的多个组成对象关系可以指导数据库设计。例如,如果聚合中存在List<值对象>,则在数据库中应建立1:N的关联,并将值对象单独建表。此时,值对象应有独立的ID,但不应将该ID暴露给外部。

如何识别聚合?识别聚合需要从业务角度深入分析,找出内聚的对象关系。这些对象在数据变化时,必须保持一致性规则。聚合不宜过大,以避免性能问题。

聚合的业务规则在修改聚合时,应在事务级别确保所有对象满足业务规则。大部分领域模型中,70%的聚合只有一个实体,即聚合根;另外30%的聚合中,通常包含两到三个实体。

Aggregate 的完整性规则完整性规则由以下两点组成:

通过聚合根访问所有代码只能通过聚合根访问系统的实体,不能随意操作任何实体。

事务范围更新每个事务范围只能更新一个聚合根及其关联的实体状态。

聚合根如果聚合只有一个实体,该实体就是聚合根。如果有多个实体,应考虑哪个对象有独立存在的意义并可与外部直接交互。

根实体每个聚合的根实体控制着聚合内的所有其他元素。

聚合根配置当实体为聚合根时,通常配置以下三个模块:

工厂(Factory)负责创建聚合根及其子实体,与实体行为无关。

领域服务(DomainService)完成聚合根内实体的相关行为,处理业务逻辑。

仓库(Repository)提供聚合根与数据存储的交互功能。

配置原则- 对于简单实体,可以使用构造函数或直接设置值。- 工厂和领域服务不应直接与数据存储系统交互,应通过仓库层。- 聚合根统一配置仓库等模块,子实体不需单独配置。

工厂工厂提供创建对象的接口,封装了创建对象的复杂过程。它解决了使用setter和构造函数创建对象时的问题。

常规创建对象方式的缺陷- 使用setter容易遗漏初始化步骤。- 使用构造函数创建对象时,不同业务场景可能需要不同的参数,导致需要定义多个构造函数。

工厂模式的优势工厂模式通过特定方法封装了对象初始化逻辑,可以自由定义方法名以表达业务含义。项目中有两种实现方式可供选择。

1
public class PolicyIssueService { public Insured createInsuredFrom(PolicyProduct product, BillingInfo billingInfo, ContactAddress contactAddress) { …… } }

上述的代码中我们定义了一个领域服务类,用来实现新保单承保的逻辑,其中的方法 createInsured 会返回一个 Insured 的实例,这就是我们定义的用来创建 Aggregate 的工厂方法。通过这样在领域服务中定义专门的方法,可以很好的封装领域对象的初始化逻辑,保证数据完整性的同时也不丢失业务含义。

Factory Pattern具体两种实现:

  1. 由领域服务提供的 Factory Method

我们之前在分层架构中提到过领域服务的概念,如果说领域对象从某种程度上代表了领域知识中的名词,那么领域服务就对应了动词。我们可以在领域服务中定义所需要的方法来返回一个 Aggregate。

1
public class PolicyIssueService { public Insured createInsuredFrom(PolicyProduct product, BillingInfo billingInfo, ContactAddress contactAddress) { …… } }

  上述的代码中我们定义了一个领域服务类,用来实现新保单承保的逻辑,其中的方法 createInsured 会返回一个 Insured 的实例,这就是我们定义的用来创建 Aggregate 的工厂方法。通过这样在领域服务中定义专门的方法,可以很好的封装领域对象的初始化逻辑,保证数据完整性的同时也不丢失业务含义。

  1. 由领域服务提供的 Factory Method

  除了在领域服务上定义相关的工厂方法之外,在 Aggregate 上也能定义专门的方法来管理另一个 Aggregate 或是 Entity 的初始化。我们通过一个保险业务上的例子来说明这种情况。当被保人发生意外,如果在保险单的保障范围内,可以申请理赔。在申请理赔时需要录入许多事故相关和保险单相关的信息,因此可以将理赔申请设计为一个 Aggregate。而初始化这个 Aggregate 的方法可以交给另一个 Aggregate,即保险单的 Aggregate。具体代码可参考如下:

1
//代表理赔申请的 Aggregate public class ClaimApplication { …… } //代表保险单的 Aggregate public class Policy { //创建 ClaimApplication 的工厂方法 public ClaimApplication applyClaimWith(Accident accident) { …… } }

  上面的方法很好理解,在 Policy 上有个方法,applyClaimWith,它接受一个事故信息 Accident 对象,返回另一个 Aggregate ClaimApplication 。当采用这种解决方案时,我们需要更多的分析领域对象之间的关系,在合理的对象上定义工厂方法,切忌在一个 Aggregate 上定义过多的工厂方法,这样也就丢失了相关的领域知识。

4.2.5 仓储

  仓储被设计出来的目的是基于这个原因:领域模型中的对象自从被创建出来后不会一直留在内存中活动的,当它不活动时会被持久化到数据库中,然后当需要的时候我们会重建该对象;重建对象就是根据数据库中已存储的对象的状态重新创建对象的过程;所以,可见重建对象是一个和数据库打交道的过程。从更广义的角度来理解,我们经常会像集合一样从某个类似集合的地方根据某个条件获取一个或一些对象,往集合中添加对象或移除对象。也就是说,我们需要提供一种机制,可以提供类似集合的接口来帮助我们管理对象。仓储就是基于这样的思想被设计出来的;仓储里面存放的对象一定是聚合,原因是之前提到的领域模型中是以聚合的概念去划分边界的;聚合是我们更新对象的一个边界,事实上我们把整个聚合看成是一个整体概念,要么一起被取出来,要么一起被删除。我们永远不会单独对某个聚合内的子对象进行单独查询或做更新操作。因此,我们只对聚合设计仓储。仓储还有一个重要的特征就是分为仓储定义部分和仓储实现部分,在领域模型中我们定义仓储的接口,而在基础设施层实现具体的仓储。这样做的原因是:由于仓储背后的实现都是在和数据库打交道,但是我们又不希望客户(如应用层)把重点放在如何从数据库获取数据的问题上,因为这样做会导致客户(应用层)代码很混乱,很可能会因此而忽略了领域模型的存在。所以我们需要提供一个简单明了的接口,供客户使用,确保客户能以最简单的方式获取领域对象,从而可以让它专心的不会被什么数据访问代码打扰的情况下协调领域对象完成业务逻辑。这种通过接口来隔离封装变化的做法其实很常见。由于客户面对的是抽象的接口并不是具体的实现,所以我们可以随时替换仓储的真实实现,这很有助于我们做单元测试。尽管仓储可以像集合一样在内存中管理对象,但是仓储一般不负责事务处理。一般事务处理会交给一个叫“工作单元(Unit Of Work)”的东西。关于工作单元的详细信息我在下面的讨论中会讲到。另外,仓储在设计查询接口时,可能还会用到规格模式(Specification Pattern),我见过的最厉害的规格模式应该就是LINQ以及DLINQ查询了。一般我们会根据项目中查询的灵活度要求来选择适合的仓储查询接口设计。通常情况下只需要定义简单明了的具有固定查询参数的查询接口就可以了。只有是在查询条件是动态指定的情况下才可能需要用到Specification等模式。

在这里插入图片描述
  仓储还有一个重要的特征就是分为仓储定义部分和仓储实现部分,在领域模型中我们定义仓储的接口,而在基础设施层实现具体的仓储。这样做的原因是:由于仓储背后的实现都是在和数据库打交道,但是我们又不希望客户(如应用层)把重点放在如何从数据库获取数据的问题上,因为这样做会导致客户(应用层)代码很混乱,很可能会因此而忽略了领域模型的存在。所以我们需要提供一个简单明了的接口,供客户使用,确保客户能以最简单的方式获取领域对象,从而可以让它专心的不会被什么数据访问代码打扰的情况下协调领域对象完成业务逻辑。这种通过接口来隔离封装变化的做法其实很常见。由于客户面对的是抽象的接口并不是具体的实现,所以我们可以随时替换仓储的真实实现,这很有助于我们做单元测试。

  对于资源库,我们的实践是资源库作为业务与数据的隔离层,屏蔽底层数据表细节,同时完成PO与DO的转化。DO与PO的转化带来的好处是领域层不会直接依赖底层实现,便于后续更换底层实现或功能迁移。资源库接口定义在领域层,接口实现在基础设施层。一些开发者可能会把 Repository 与 DAO 混淆在一起,由于 Spring JPA 这样的框架在命名方面把两者交织在一起,更加容易加深大家的误解。Repository 从字面以上来看更加偏重业务的含义,作为一个「仓库」它所要做的是将领域对象重新拿出来,但是不必关心底层的细节。例如我们是使用一种关系型数据库,还是 NoSQL 数据库,作为领域层其实是不关心的,它们关心的是领域对象是否被正确的还原出来。而 DAO 在实际项目中往往会更底层些,它抽象的是不同关系型数据库的异同,你可以使用 MySQL,也可以使用 Oracle,但是对于 DAO 层暴露的接口应该是相同的。我们来看一个具体的例子。

1
public interface InsuredRepository { public void save(Insured insured); public Insured findBy(Long id); …… } public interface ProductRepository { public void save(Product product); public Product findBy(Long id); …… }

以上的代码中我们对两个领域对象,Insured 与 Product 定义了两个 Repository 接口,用以与某种存储机制进行交互。接下来看我们的实现。

1
public abstract class InsuredDBDAO implements InsuredRepository { …… } public class MySQLInsuredDBDAO extends InsuredDBDAO { …… } public class MongoDBProductRepository implements ProductRepository { …… }

  我们使用关系型数据库存储 Insured 的数据,同时为了保证不耦合到特定的关系型数据库,我们定义了一个额外的 DAO 抽象类,然后提供了基于 MySQL 实现的具体类。而在 Product 这方面,我们更希望使用 MongoDB 这样一个 NoSQL 存储数据,因此我们直接使用了一个具体的类实现了 ProductRepository 的接口。但是这两个接口在领域层暴露的几口都是一致的,所以需要牢记的是 Repository 是属于领域层的,而具体存储机制的实现,无论是 DAO 还是其他的实现,都应该属于 infrastructure 层,属于具体的实现机制。

仓库层注意事项

  • 仓库层入参不应该使用底层数据格式,Repository操作的是Entity对象(实际上应该是Aggregate Root),而不应该直接操作底层的数据对象(数据表映射的贫血对象)。更近一步,Repository接口实际上应该存在于Domain层,根本看不到数据层的实现。这个也是为了避免底层实现逻辑渗透到业务代码中的强保障。
  • 实体状态变更,行为处理,仓库层入参可以接收处理命令(如XxxUpdateCommand)。
  • 仓库层接口放在领域层模块,但仓库层的实现放在基础层模块。
  • 当发现数据存储要求更多的字段,实体缺乏某些数据项时(如一些加工生成的中间数据),不要将缺少的数据,通过参数的方式传递到仓库层。应反思实体是否设计的完善合理,尽可能的完善实体后,再存储数据。
  • 仓库层的业务接口入参一般为实体,实体的唯一身份标识,部分基础数据类型。
  • 仓库层的查询接口入参可以为Query对象,单个主键编码。
  • 仓库层的数据操作接口,原则上由应用层调用,不要在领域层中调用,领域层一般调用查询接口。

4.2.6 领域事件

定义:
  领域事件其实比较好理解的,就是当某个领域触发变更之后,通知其他领域的事件。它是领域专家所关心发生在领域中的一些事。领域事件本身也作为通用语言(Ubiquitous Language)的一部分成为包括领域专家在内的所有项目成员的交流用语。比如:如果你建模的是餐厅的结账系统,那么此时的“客户已到达”便不是你关心的重点,因为你不可能在客户到达时就立即向对方要钱,而“客户已下单”才是对结账系统有用的事件。

特征:
较高的业务价值,有助于形成完整的业务闭环,将导致进一步的业务操作。这里还要强调一点,领域事件具有明确的边界。举个例子:比如说在交易场景下,订单支付成功之后,我们是需要增加用户积分的。在这种场景下,订单实体是需要发出订单支付成功的事件,用来通知用户的积分予以去做增长积分的一个行为。

关键词汇:

  • “当…”
  • “如果发生…”
  • “当…时候,请通知我”
  • “发生…时”

判断是否是一个领域事件时并不能完全根据词汇上述只是一个常规案例,具体还要根据实际业务含义分析。一个很明显的特性就是领域事件具备很重要的特征。它在业务进程中是很重要的一环。

4.2.7 领域服务

概念:
  当一个操作不适合放在聚合和值对象中时,最好的方式便是使用领域服务。我们要尽量不要将领域服务与应用服务相混淆。在应用服务中,我们并不会处理业务逻辑,但领域服务却恰恰是处理业务逻辑的。虽然领域服务中有“服务“这个词,但它并不意味着需要远程的、重量级的事务操作。一般实体行为的具体业务规则实现,单独编写一个实现类,这种类在DDD里被叫做领域服务(Domain Service)。

作用背景:
  领域中的一些概念不太适合建模为对象,即归类到实体对象或值对象,因为它们本质上就是一些操作,一些动作,而不是事物。这些操作或动作往往会涉及到多个领域对象,并且需要协调这些领域对象共同完成这个操作或动作。如果强行将这些操作职责分配给任何一个对象,则被分配的对象就是承担一些不该承担的职责,从而会导致对象的职责不明确很混乱。但是基于类的面向对象语言规定任何属性或行为都必须放在对象里面。所以我们需要寻找一种新的模式来表示这种跨多个对象的操作,DDD认为服务是一个很自然的范式用来对应这种跨多个对象的操作,所以就有了领域服务这个模式。和领域对象不同,领域服务是以动词开头来命名的,比如资金转帐服务可以命名为MoneyTransferService。当然,你也可以把服务理解为一个对象,但这和一般意义上的对象有些区别。因为一般的领域对象都是有状态和行为的,而领域服务没有状态只有行为。需要强调的是领域服务是无状态的,它存在的意义就是协调领域对象共完成某个操作,所有的状态还是都保存在相应的领域对象中。我觉得模型(实体)与服务(场景)是对领域的一种划分,模型关注领域的个体行为,场景关注领域的群体行为,模型关注领域的静态结构,场景关注领域的动态功能。这也符合了现实中出现的各种现象,有动有静,有独立有协作。

领域服务还有一个很重要的功能就是可以避免领域逻辑泄露到应用层。因为如果没有领域服务,那么应用层会直接调用领域对象完成本该是属于领域服务该做的操作,这样一来,领域层可能会把一部分领域知识泄露到应用层。因为应用层需要了解每个领域对象的业务功能,具有哪些信息,以及它可能会与哪些其他领域对象交互,怎么交互等一系列领域知识。因此,引入领域服务可以有效的防治领域层的逻辑泄露到应用层。对于应用层来说,从可理解的角度来讲,通过调用领域服务提供的简单易懂但意义明确的接口肯定也要比直接操纵领域对象容易的多。这里似乎也看到了领域服务具有Façade的功能。

场景:

  • 执行一个显著的业务操作过程
  • 对领域对象进行转换
  • 以多个领域对象作为输入进行计算,结果产生一个值对象
跨对象事务型(多实体)-第三方领域服务

当一个行为会直接修改多个实体时,不能再通过单一实体的方法作处理,而必须直接使用领域服务的方法来做操作。在这里,领域服务更多的起到了跨对象事务的作用,确保多个实体的变更之间是有一致性的。该领域服务是一个多实体的综合服务实现类,不是任何一个单独实体的领域实现类。
如账户转账模块:有两个账户实体,一个支出,一个收入,两个实体的行为同时完成支出和收入才算转账完成。

  很多人认为DDD中的聚合就是在与贫血模型做抗争,所以在领域层是不能出现“service”的,这等于是破坏了聚合的操作性。但有些重要的领域操作无法放到实体或值对象中,这当中有些操作从本质上讲是一些活动或动作,而不是对象。比如我们的身份认证、支付转账业务,我们很难去抽象一个金融对象去协调转账、收付款等业务逻辑;有时候我们也不太可能让对象自己执行auth逻辑。因为这些操作从概念上来讲不属于任何业务对象,所以我们考虑将其实现成一个service,然后注入到业务领域或者说是业务域委托这些service去实现某些功能。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

常见问题汇总:

  • 如何识别聚合根?

  如果一个聚合只有一个实体,那么这个实体就是聚合根;如果有多个实体,那么我们可以思考聚合内哪个对象有独立存在的意义并且可以和外部直接进行交互。

  • 一个聚合如何访问另外一个聚合?

  只有聚合根才是访问聚合边界的唯一入口,因此一个聚合需要通过另一个的聚合的聚合根来访问它,聚合根可以理解为聚合的根实体的Id。

  • 如何创建好的聚合?

  边界内的内容具有一致性:在一个事务中只修改一个聚合实例。如果你发现边界内很难接受强一致,不管是出于性能或产品需求的考虑,应该考虑剥离出独立的聚合,采用最终一致的方式。
设计小聚合:大部分的聚合都可以只包含根实体,而无需包含其他实体。即使一定要包含,可以考虑将其创建为值对象。
聚合之间的关联通过ID,而不是对象引用
聚合内强一致性,聚合之间最终一致性

  • 应用服务与领域服务的区别?

  领域服务处在分层架构的领域层,是领域逻辑的一部分。应用服务处在应用层,负责领域模型的编排。当业务逻辑不属于任何聚合时,应该考虑用领域服务来封装这些逻辑。比如判定订单是否重复,应该属于订单限界上下文的一种业务逻辑,订单聚合本身不能判断是否重复,因此订单判重应该定义为领域服务。

  • 应用服务可以直接调用聚合和资源库吗?

可以,可被应用服务编排的对象包括聚合、资源库、领域服务和适配接口。

  • 值对象可以定义自己的行为吗?

  可以,尽可能把属于值对象自己的行为放到值对象里。比如联系方式定义成一个值对象,如果它的校验只依赖自身数据,那校验行为应该属于在联系方式这个值对象。

  • 实体和值对象之间什么关系?

  唯一的身份标识和可变性特征将实体对象和值对象进行了区分。本质上,实体是看得到、摸得着的实实在在的业务对象,实体具有业务属性、业务行为和业务逻辑。而值对象只是若干个属性的集合,只有数据初始化操作和有限的不涉及修改数据的行为,基本不包含业务逻辑。

实体和值对象是微服务底层的最基础的对象,一起实现实体最基本的核心领域逻辑。同时实体对象和值对象共同构成了聚合。

在设计的时候应该用实体对象还是值对象,我觉得本着一个是否具有业务行为的原则就够了,有业务行为的就用实体对象,没有业务行为的就设计成值对象。

  • BC与微服务什么关系?

微服务是包含高度相关功能的一个开发部署单元,有自己的技术自治性包括技术选型、弹性扩缩容、发布上线频率等,有自己的业务演变自治性。BC是根据领域逻辑的内聚情况形成的一个整体。一个微服务可以包含一个或多个BC,到底包含几个?需要根据团队大小、BC复杂度和技术特性来定。

  • 业务处理如何依赖实体行为?
    一个业务的完成,往往会关联多个实体,需要多个实体的不同行为协调运行。
    如在售后补偿中,补偿单整体流程结束:包括履约单完成和补偿单状态完成。两个实体都完成了,才算整个补偿业务完成。不同实体之间不能直接调用,参考实体的行为规范,针对多个实体的情况,一般存在以下三个常用场景:

1.多实体强一致性:完成一个业务必须保证相关的实体同时完成,具有事务性质。可采用第三方领域服务处理,处理完成后,在应用层加上事务保证数据一致性的存储到系统。

2.实体副作用:完成一个实体后,其他实体监听处理,实体不依赖于其他实体的处理结果。如履约单完成后,发出一个事件。

3.多实体先后处理:在应用层,应用服务调用领域层实体相关的功能,做业务编排,先执行一个实体的行为,在执行其他实体的行为。如补偿单审批通过后,调用履约单的处理功能。

  • 为什么资源库只提供一个save方法持久化聚合根?
      原因是在DDD中,资源库是聚合根的容器,但并不限制容器是什么做的,也就是前面说的与底层解耦。如果容器是Key-value数据库做的,是不支持update某个字段的,并且inset和update是不区分的。资源库与DAO不同,资源库只是向领域模型提供聚合根以及持久化聚合根。
      如果我们选择关系型数据库作为聚合根的容器,那么在存储聚合根时可能就需要将聚合根以及聚合根下的实体拆分到多个表存储,这就可能导致每次save聚合根都需要执行多条update语句,即便聚合根下的实体并没有发生任何的改变,即便只是聚合根修改了一个值对象,因此会严重影响到应用的性能。为解决选择关系数据库作为聚合根容器导致的性能问题,我们需要付出额外的努力,如用内存快照去判断每次save聚合根只需要更新哪些表。基于每个业务用例都需要通过资源库获取聚合根最后也通过资源库持久化聚合根的特性,我们可以在获取聚合根时创建快照,并且在持久化聚合根时对比(diff)快照,获取差异信息,只执行需要更新的差异信息。