DDD Terminology
DDD Terminology Learning
开篇总览,为什么需要DDD(Domain-Driven Design)。首先它并不是纯technical,它包括了一整套软件实践套路,这个套路经过实践是可以应对软件实现中的许多问题,尤其是如今microservice下。具体用DDD的术语去解释:通过通用语言
聚合
命令
领域事件
在业务概念上与团队各角色成员达成一致。实现ba/qa <-> dev <-> code之间的无障碍交流。code as design , design as code
战略模式
提供一种方法,从更高的层次去归纳
与划分业务。首先需要理解以下术语
- 核心域 Core Domain:业务成功的主要促成因素。最高优先级。
- 子域 Subdomain:不是核心的域。 如支撑子域和通用子域。
- 限定上下文:显式的边界之于领域模型。不同上下文中通用语言有各自的含义。
上图展示了一个抽象的业务领域。注意虚线内是域,而限定上下文有物理上的边界。
通过domain的划分,更好的识别限定上下文与其中的领域模型。从而对多个bounded-context下的domain model归纳。或者将不属于当前上下文的domain-model抽离,放到新的上下文中。
- 上下文映射图 Context Maps:将不同bounded-context连接起来的桥梁,有多种方式如ACL防腐层adapter、OHS开放主机服务restful、PL发布语言rpc。
通过context maps将code通过design显示出来,如下
strategic design套路:event-storming
- 事件风暴列出所有event,上白板
- 命令风暴:user+command。在event基础上,定义发起人user+发起动作command。与event一起上白板。一个command可能包含多个event
- 寻找聚合:提取上面相关的名称成为一个聚合,写在tips旁边。称为聚合
- 画出限定上下文,与context-map
根据上面的聚合,就可以在structure级定义package了。
战术模式
实体
想想之前的传统开发,首先考虑的是数据库设计(表的列和外键等),将数据库模型直接反映在Java实体对象上,导致大量的getter/setter方法的贫血对象。而DDD不是这么做的,下面依次介绍DDD entity
一个实体是唯一的东西,并且可以被多次的改变,但是不论怎么变化,实体的identity都会标识它的唯一性。这个唯一标识本身只是一个值对象,可以是一个自定义的强类型,增加代码可读性。
值对象
我们应该尽量使用值对象来建模而不是实体对象。值对象用于度量
和描述事物
,可以非常容易的进行创建测试使用优化和维护。值对象使用后直接扔掉,不用关心客户端对其的修改。
一个值对象在创建之后就不能改变了,即不变性
。所以值对象里的方法应该是无副作用函数
,保证不变性。
持久化:
关于值对象如何持久化,牵涉到 数据建模(以往方式) 和 领域建模(DDD) 对我们的model产生的影响。
DDD倡导通过领域模型来设计数据模型,即类->表。而不是通过表->类。所以,在设计持久化实体的值对象时,保证领域模型干净, 隐藏持久化相关的信息。持久化只是我们的领域模型保存的手段,而不应该影响或暴露技术到我们的领域模型中。
所以,这也就能理解了,一个值对象存储方式:
- 单个值对象:字段打平放在实体表中。filed name遵循valuename_xxx
- 多个值对象:存json放在实体表中,这会产生查询/列宽/string到collection转换等问题。或 仍然使用以往数据库设计方式,通过FK关联主表,但要在领域模型中隐藏hibernate这些细节,因为它只是我们持久化的一种方式。具体如何持久化要放Repository里,这样也是实现了model与持久化层的解耦。
总结,实体和值对象的持久化,不应该是我们建模时考虑的问题,DDD首要要从领域建模,让model体现出领域的特征,具体持久化放到repository里,这也就理解了,有些DDD boilerplate在repository层使用JDBC手操sql持久化数据。
领域服务
应用服务application service是领域模型的客户方,进而也是领域服务domain service的客户方(client)。application service中不会处理业务逻辑,它用于协调操作domain model,如控制事务/安全认证/三方集成等。
那什么是domain service呢,当一个方法不属于实体或值对象时,那就该使用domain service。与领域相关的业务信息,不能泄露到client端,所以也就促成了领域服务。
注意:它并不是银弹,应该尽量避免使用。否则又会和传统一样出现贫血对象。
领域事件
用来捕获领域中的一些事情。场景:单个事务中只允许对一个聚合实例进行修改,由此其他聚合实例可以通过领域事件的方式进行修改同步。如何识别:一个聚合依赖另一个聚合,此时我们需要保证它们的最终一致性。
命名规则:事件要体现出方法执行成功后发生的事情,一般是过去分词做名称的限定。
- 方法:BacklogItem#commitTo(Sprint aSprint)
- 事件:BacklogItemCommitted
从领域模型中发布领域事件,坚决不要把domain model和infrastructure显示的耦合在一起。在同一bounded-context下可以通过发布-订阅模式发布event,在不同跨服务时,通过消息组件甚至REST实现消息发布机制。
事件存储:首先存储event的好处有,bug追踪/业务分析/重建聚合等。存储后进而发布,注意consumer的幂等接受以应对publisher重复event。当然这些问题都有具体的解决方法,不论REST/MQ。在DDD里我们只要记住domain event何时使用,如何通过存储事件应对出错。
聚合
什么是聚合?实体/值对象/其他聚合组成的对象树?这棵树多大合适?下面是设计聚合的原则
关注聚合的一致性边界。有点抽象,简单说 对聚合的一个操作,满足事务的一致性。 即在这个事务中,对相关实体/值对象表的操作,要么同时成功,要么同时失败。操作不会产生让业务困惑的不一致结果。
还是很抽象,这个原则的目的:让我们去分解
大聚合
,找出聚合里那些需要一致性边界的属性,而不需要一致性的是否考虑新建聚合等。举例:对product里的release和sprint的修改,并不是要放到一个事务里的。release和sprint并没有业务上的一致性要求。所以它们可以拆开,下一条就是怎么拆。
设计小聚合。
对于大聚合即使可以保证事务成功执行,但可能会限制到系统的性能和可伸缩性。尤其对于延迟加载的持久化机制如hibernate,会不小心把所有的集合加载到内存里。
好的做法:使用根实体来表示聚合,其中只包含最小数量的属性或值类型属性。
通过唯一标识引用其他聚合。但是引用的聚合不可以和源聚合在同一个事务中进行修改。
这种唯一标识引用的方式,解决了之前大聚合带来的性能问题。但业务仍需要操作引用聚合时,如果直接在聚合中操作repository这就和延迟加载一样了。所以,推荐在调用聚合行为方法前,通过repository或domain-service将需要的聚合传递给它,最后在application-service中组装起来。
在边界之外使用最终一致性。
一次请求修改多个聚合实例,同时要保证一致性时,请使用最终一致性。对于不同聚合实例之间的延迟,有时是可以允许的或者给这种状态一个业务定义。
DDD中使用在聚合的命令方法里发布event,订阅方在操作聚合,这样也满足一个事务中只修改一个聚合的原则。
打破原则:
有时候需要在单个事务中更新多个聚合实例。如果这些聚合实例都属于同一类聚合,则它和创建单个聚合实例无区别。这种情况是可以打破原则。
有时项目没有消息系统/定时器/后台线程之类的技术,所以就无法实现最终一致性。这种很容易陷入大聚合,降低了系统性能和可伸缩性。
在处理遗留系统或政策要求,必须使用全局事务时,我们至少可以在自己的模型中消除事务竞争,避免一次性修改多个聚合实例。总之,就是要避免全局事务。它会让我们的系统 很难有好的伸缩性。
工厂
用于创建对象,除此之外工厂不承担领域模型中的其他职责。它接收一些基本的参数(通常是值对象),这样也达到隐藏创建细节的目的,同时工厂方法也更好的表达通用语言。
对于需要外部依赖的Factory,创建对象细节不应该放在ApplicationService中,所以就有了单独的Factory类。
资源库
只要聚合根才配有repository,用于连接领域层和基础结构层,提供聚合根的持久化机制。通过repository可以解耦领域层和ORM的联系,面向接口编程,提供更多的灵活性。
首先,聚合根一定是实体对象,其次,聚合根还可以拥有其他实体对象,所以聚合根它是一个概念,一个树状模型具有一致性边界。
对于save操作,repository里保存聚合根中的实体/值对象没什么可说的。
对于query操作,我们只需要聚合中中的某些子聚合,repository返回一个大而全的聚合根有时会有性能问题,出于性能考虑我们返回其中的子聚合,但请少用。
同时有些use case optimal query用例优化查询中,repository是可以直接返回值对象的。但同时过多的query会让repository失去原本的意义:聚合根的容器
。这个容器只是向领域模型提供聚合根而已。
关于读又牵扯出DDD中的读操作,它和写操作是不一样的。DDD中的写操作按照"应用服务->聚合根->资源库
"这个结构进行编码,而读操作按照这个流程,会使整个过程冗长累赘,毕竟有些情况下不需要聚合根的所有属性,同时也不应该将聚合根直接返回给客户端。
DDD之前采用的是居于领域模型的读操作,即通过repository读出整个聚合根进而转化成representation。缺点很明显:读操作受限于聚合根的边界,需要多个聚合根时,需要内存中进行转化,这种繁琐而低效各种性能问题。同时过多query在repository中会越来越复杂,不知不觉就偏离了repository原本的聚合根容器的意义,不应该将查询逻辑放到repository中。
DDD是基于数据模型的读操作,这种方式绕开了资源库和聚合,直接从数据库中读取客户端所需要的数据。会有一个单独的representationService单独来处理读返回相应的representation,这样就和DDD的写操作独立开来,读模型不会受到聚合根的牵制,简化了流程提高了性能。这种RepresentationService也是迈向CQRS的一种折中。Microsoft Simplified CQRS。
事务管理:
对事务的管理绝对不应该放在领域模型和领域层中。通常来说,对领域模型的操作都是非常细粒度的,以至于无法用于管理事务。另外,领域模型也不应该意识到事务的存在。我们将事务管理放在应用层。
用户界面
一个application需要user-interface来渲染领域对象进行展示。这里讨论的是如何将领域对象更高效合理的返回出去,因为DDD的读操作是很特殊。
DTO:
数据传输对象用来组织用户界面所需的属性。注意我们这里的DTO是针对用户展示而言的,并不是BFF下游service提供的元数据。
所以,有两种情况。对用户展示的情况,在设计这个DTO时,更应该基于user-case来设计,而不是aggregate的一个copy,这个DTO相关的转化代码就放在DTO本身或者assemble(有依赖情况)里。
对BFF提供元数据的情况,也就是说我们service提供REST资源,这个资源看成一个单独的模型-view/presentation model,它不应该和aggregate状态一对一映射,也不应该持有aggregate引用的其他聚合的link(id)。
否则下游在使用这个REST资源时候,就要和aggregate一样需要知道如何操作state和link,同时也失去了我们单独抽出这个抽象层的好处,所以如何返回这个聚合,才能保证我们业务的高内聚,需要好好设计这个view model。
当然没有绝对,比如获取一个list时候,如果返回一个大而全的view,涉及到多张表会有性能问题,特别是BFF for-each call client时。这时我们应该采用
例优化查询
。例优化查询:
相比读多个aggregate然后组装到一个DTO里,你应该使用user-case-optimal-query。它是创建一个值对象只针对当前user-case,这不是一个DTO。
它只返回聚合中的一些属性或汇总结果等。它的出发动机和CQRS相似,将DDD的读操作分离出来,进而更好的设计聚合不受读操作来影响aggregate字段设计。