实现领域驱动设计(DDD)系列详解:如何设计实体和值对象,进行领域建模

一、 软件中的模型

1.什么是模型?

为了创建真正能为用户活动所用的软件,开发团队必须运用一整套与这些活动有关的知识体系。

所需知识的广度可能令人望而生畏,庞大而复杂的信息也可能超乎想象。模型正是解决此类信息超载问题的工具。

模型这种知识形式对知识进行了选择性的简化和有意的结构化。

适当的模型可以使人理解信息的意义,并专注于问题。
如何才能让庞大而复杂的信息变得更加简单,让分析人员的心智模型可以容纳这些复杂的信息呢?

那就是利用抽象化繁为简,通过标准的结构来组织和传递信息,形成可以推演的解决方案。这就是模型。

2.如何表达模型?

模型往往是交流的有效工具,因而需要用经济而直观的形式来表达,其中最常用的表现形式之一就是图形。例如,轨道交通线网图。
模型具有许多特点,它是抽象的,是可视化的,传递了重要的模型要素。

模型的重要性并不体现在它的表现形式,而在于它传递的知识

它是从需求到编码实现的知识翻译器,通过对杂乱无章的问题进行梳理,消除无关逻辑乃至次要逻辑的噪声,然后按照知识语义进行分类与归纳,遵循设计标准与规范建立一个清晰表达业务需求的结构。这个梳理、分类和归纳的过程就是建模的过程,建立的结构即模型。

3.建模过程是怎样的?

建模过程与软件开发生命周期的各种不同的活动息息相关
在这里插入图片描述

为了便于更好地理解建模过程,我将整个建模过程中主要开展的活动称为“建模活动”,并统一归纳为分析活动、设计活动和实现活动。每一次建模活动都是一次对知识的提炼和转换,产出的成果就是各个建模活动的模型。

  • 分析活动:观察真实世界的业务需求,依据设计者的建模观点对业务知识进行提炼与转换,形成表达了业务规则、业务流程或业务关系的逻辑概念,建立分析模型。
  • 设计活动:运用软件设计方法进一步提炼与转换分析模型中的逻辑概念,建立设计模型,使得模型在满足需求功能的同时满足更高的设计质量。
  • 实现活动:通过编码对设计模型中的概念进行提炼与转换,建立实现模型,构建可以运行的高质量软件,同时满足未来的需求变更与产品维护。
    在这里插入图片描述

一个完整的建模过程,就是模型驱动设计。

4.如何进行模型驱动设计?

在进行模型驱动设计时,同样需要区分问题空间和解空间,否则,就可能会将问题与解决方案混为一谈,在不清楚问题的情况下开展建模工作,从而输出一个错误模型,无法真实地反映真实世界。

即使面对同一个问题空间,当我们采取不同的视角对问题进行分解时,也会引申出不同视角的解决方案,并驱使我们建立不同类型的模型。

将问题空间抽取出来的概念视为数据信息,在求解过程中关注数据实体的样式和它们之间的关系,由此建立的模型就是数据模型。

将每个问题视为目标系统为客户端提供的服务,在求解过程就会关注客户端发起的请求以及服务返回的响应,由此建立的模型就是服务模型

围绕着问题空间的业务需求,在求解过程中力求提炼出表达领域知识的逻辑概念,由此建立的模型就是领域模型

毫无疑问,领域驱动设计选择的建模过程,实则是领域模型驱动设计。
在这里插入图片描述

针对真实世界的问题空间建立抽象的模型,会组成一个由抽象领域概念组成的理念世界。理念世界是真实世界问题空间向解空间的一个投影,投影的方法就是对问题空间求解的方法。在领域驱动设计中,这个求解方法就是领域建模。

5.如何将问题空间映射为解空间的领域模型?

当系统规模达到一定程度后,软件复杂度陡然增加,要想直接将问题空间映射为解空间的领域模型,需要极高的驾驭能力。
在这里插入图片描述
架构映射阶段在这个过程中起到了关键的架构支撑作用。以限界上下文为核心要素构建的架构是在更高的抽象层次上对业务的划分,故而它的稳定性要强于领域模型。

菱形对称架构与系统分层架构沿着变化方向与维度对关注点进行了有效的切分,提高了整个系统响应变化的能力。映射获得的架构形成了支撑这个系统的骨架,确保它应对风险和响应变化的能力。

整个系统的解空间通过限界上下文进行了分解,使得整个系统的规模得到了有效的控制。真实世界投射而成的理念世界被限界上下文分割为多个小的解空间。对限界上下文进行领域建模,就相当于对一个小规模的软件系统进行领域建模。

6.领域模型究竟是什么?

是使用建模工具绘制出来的UML图?是通过编程语言实现的代码?或者干脆就是一个完整的书面设计文档?我认为,UML图、代码和设计文档仅仅是表达领域模型的一种载体。绘制出来的UML图或者编写的代码与文档如果没有传递领域知识,那就不是领域模型。因此,领域模型应该具备以下特征:

  • 运用统一语言来表达领域中的概念;
  • 蕴含业务活动和规则等领域知识;
  • 对领域知识进行适度的提炼和抽象;
  • 由一个迭代的演进的过程建立;
  • 有助于业务人员与技术人员的交流。

模型驱动设计不再将分析模型和程序设计分离开,而是寻求一种能够满足这两方面需求的单一模型。

这说明分析模型和程序设计应该一起被放入同一个模型中。这个单一模型就是“领域模型”。

软件系统各个部分的设计应该忠实地反映领域模型,以便体现出这二者之间的明确对应关系。

同时,从模型中获取用于程序设计和基本职责分配的术语。让程序代码成为模型的表达。

分析真实世界后提炼出的概念模型,就是领域分析模型
设计对领域模型的反映,就是领域设计模型
代码对领域模型的表达,就是领域实现模型

领域分析模型、领域设计模型和领域实现模型在领域视角下,成了领域模型中相互引用和参考的不可或缺的组成部分,它们分别是分析建模活动、设计建模活动和实现建模活动的产物。

在这里插入图片描述
模型驱动设计非常强调模型的一致性。Eric Evans甚至认为:“将分析、建模、设计和编程工作过度分离会对模型驱动设计产生不良影响。”

因此,倘若我们围绕着“领域”为核心进行设计,采用的就是领域模型驱动设计,整个领域模型应该包含图所示的领域分析模型、领域设计模型和领域实现模型。

从表面上看,定义那些用来捕获领域概念的对象很容易,但要想反映其含义却很困难。这要求我们明确区分各种模型元素的含义,并与一系列设计实践结合起来,从而开发出特定类型的对象。

(1)一个对象是用来表示某种具有连续性和标识的事物的呢?(Entity实体)

(2)还是用于描述某种状态的属性呢?(valueObject值对象)

(3)领域中还有一些方面适合用动作或操作来表示,这比用对象表示更加清楚。这些方面最好用领域服务SERVICE来表示,而不应把操作的责任强加到ENTITY或VALUE OBJECT上,尽管这样做稍微违背了面向对象的建模传统。SERVICE是应客户端请求来完成某事。

二、领域分析建模

1.关系

例如,学生与老师的关联的模型有两个含义。一方面,它把开发人员所认为的两个真实的人之间的关系抽象出来。另一方面,它相当于两个Java对象之间的对象指针,或者相当于数据库查询(或类似实现)的一种封装。

public class Teacher{
	private String name;
	private Student student;
}
public class Student {
	private String name;
	private Teacher teacher;
}
// teacher表
CREATE TABLE teacher(
    id INT NOT NULL AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    student_id INT,
);
// student 表
CREATE TABLE student (
    id INT NOT NULL AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    teacher_id INT,
);

现实生活中有大量“多对多”关联,其中有很多关联天生就是双向的。我们在模型开发的早期进行头脑风暴活动并探索领域时,也会得到很多这样的关联。但这些普遍的关联会使实现和维护变得很复杂。此外,它们也很少能表示出关系的本质。

至少有3种方法可以使得关联更易于控制。
(1)规定一个遍历方向。
(2)添加一个限定符,以便有效地减少多重关联。
(3)消除不必要的关联。

像很多国家一样,美国有过很多位总统。这是一种双向的、一对多的关系。然而,在提到“乔治·华盛顿”这个名字时,我们很少会问“他是哪个国家的总统?”。

从实用的角度讲,我们可以将这种关系简化为从国家到总统的单向关联。这种精化实际上反映了对领域的深入理解,而且也是一个更实用的设计。它表明一个方向的关联比另一个方向的关联更有意义且更重要。

下面是一个Java代码示例,演示了如何使用这三种方法来解决“多对多”关联问题:

// 定义A表和B表的实体类
class A {
    private int id;
    private String name;
    // ...
}

class B {
    private int id;
    private String name;
    // ...
}

// 定义A表和B表之间的关联表
class AB {
    private int aId;
    private int bId;
    // ...
}

// 使用第一种方法来解决“多对多”关联问题
public List<B> findBsByA(A a) {
    // 从AB表中查询所有与A表关联的B表记录
    List<AB> abs = abRepository.findByAId(a.getId());

    // 将AB表中的B表ID提取出来
    List<Integer> bIds = abs.stream().map(AB::getBId).collect(Collectors.toList());

    // 从B表中查询所有ID在bIds中的B表记录
    return bRepository.findAllById(bIds);
}

// 使用第二种方法来解决“多对多”关联问题
public List<B> findBsByAWithLimit(A a, int limit) {
    // 从AB表中查询所有与A表关联的B表记录,并限制返回结果的数量
    List<AB> abs = abRepository.findByAId(a.getId(), PageRequest.of(0, limit));

    // 将AB表中的B表ID提取出来
    List<Integer> bIds = abs.stream().map(AB::getBId).collect(Collectors.toList());

    // 从B表中查询所有ID在bIds中的B表记录
    return bRepository.findAllById(bIds);
}

// 使用第三种方法来解决“多对多”关联问题
public void deleteUnnecessaryAs() {
    // 从A表中查询所有没有与B表关联的A表记录
    List<A> as = aRepository.findByBIdIsNull();

    // 删除所有没有与B表关联的A表记录
    aRepository.deleteAll(as);
}

三、领域设计建模

(一)实体概述(Entity,又称REFERENCE OBJECT)

很多对象不是通过它们的属性定义的,而是通过连续性和标识定义的。

(1)哲学中的实体与设计数据库时的实体有什么区别?

实体(entity)这个词被我们广泛使用,甚至过分使用。

设计数据库时,我们用到实体:“实体是一个重要的概念,企业希望建立和存储的信息都是关于实体的信息。

在分解系统的组成部分时,我们用到实体:“实体也称为部件、模块、例程、配件等,就是用来构成全体的各个小块。”

从哲学中搬来实体的概念。亚里士多德认为实体是我们要描述的主体,巴门尼德则认为实体是不同变化状态的本体

这两个颇为抽象的论断差不多可以表达领域驱动设计中“实体”这个概念,那就是能够以主体类型的形式表达领域逻辑中具有个性特征的概念,而这个主体的状态在相当长一段时间内会持续地变化,因此需要一个身份标识来标记。

(2)什么是标识?

我们一般会认为,一个人有一个标识,这个标识会陪伴他走完一生(甚至死后)。这个人的物理属性会发生变化,最后消失。他的名字可能改变,财务关系也会发生变化,没有哪个属性是一生不变的,但标识却是永久的。我跟我5岁时是同一个人吗?这种听上去像是纯哲学的问题在探索有效的领域模型时非常重要。

即便最普通的“客户”对象也可能具有丰富多彩的一面。如果按时付款的话客户信用就会提高,如果未能付款则将其移交给账单清缴机构。当销售人员将客户数据提取出来,并放到联系人管理软件中时,“客户”对象在这个系统中就开始了另一种生活。无论是哪种情况,它都会被扁平化以存储在数据库表中。当业务最终停摆的时候,客户对象就“退休”了,变成归档状态,成为先前自己的一个影子。

(3)实体的标识与JAVA对象的标识有什么区别?可以将对象标识作为实体标识吗?

由于面向对象语言会给对象创建唯一对象标识,在每个对象中都构建了一些与“标识”有关的操作(如Java中的“==”操作符),这个问题变得有点让人困惑。

这些操作通过比较两个引用在内存中的位置(或通过其他机制)来确定这两个引用是否指向同一个对象。从这个角度讲,每个对象实例都有标识。

然而,在将对象持久化之后,都会从数据库中检索并重新创建一个对象实例,这些对象的原有标识就丢失了。或者每次在网络上传输对象时,在目的地也会创建一个新实例,这也会导致标识的丢失。

当创建一个用于将远程对象缓存到本地的Java运行时环境或技术框架时,这个领域中的每个对象可能确实都是一个ENTITY。但这种标识机制在其他应用领域中却没什么意义。

标识是ENTITY的一个微妙的、有意义的属性,我们是不能把它交给语言的自动特性来处理的。因此,不可以将对象标识作为实体标识。

(4) 在现实世界中,是不是每一个事物都必须有一个标识?

标识重不重要,完全取决于它是否有用。实际上,现实世界中的同一个事物在领域模型中可能需要表示为ENTITY,也可能不需要表示为ENTITY。

例如,在体育馆订票系统中的一排座椅,当进入体育馆有座次区别时,座椅是实体。其标识符就是座位号,它在体育场中是唯一的。当进入体育馆靠入场券,不区分座次,座椅是值对象。所以实体和值对象的区分依赖于具体环境。

(二) 实体标识的设计与生成

每个ENTITY都必须有一种建立标识的操作方式,以便与其他对象区分开,即使这些对象与它具有相同的描述属性。

如何才能判定两个对象是否表示同一个概念ENTITY?标识是在模型中定义的。定义标识要求理解领域。

标识不是现实中实体固有的,它是主观和客观之间的一种识别符号。正因为标识不是客观世界固有的,而是带有主观偏见和主观视角,所以标识是否存在取决于关注的重点是什么,而关注的重点决定了解决方案是什么,从而决定了软件系统是什么样的。

一个实体首先是一个“类”,属于一个类别,这是类别的抽象形式,它还拥有自己的内容数据,这些内容数据需要以一个标识来标记。标识是实体的内容抽象,只有类别的区分是不够的,需要对同一种类别下的数据实例进行区分。

“类”是分类标准,而“对象实例”代表类的一个实例,实体的对象实例则需要使用标识标记,而普通的对象实例可能就没有这个要求,实体的对象实例如果没有标识,就很难在仓储或数据库中找到它,如果找不到它,数据也就没有意义了。

实体的标识主要涉及实体对象实例的创建以及它从生到死的生命周期管理,这个过程需要标识来标记,如果没有标识,将无法管理一个个实体对象实例,也就无法管理它们代表的业务数据和逻辑。

2.1定义标识的方式:

(1)多个属性的组合

有些实体的身份标识规定了一定的组合规则,某些数据属性或属性组合可以确保它们在系统中具有唯一性,或者在这些属性上加一些简单约束可以使其具有唯一性。这种方法为ENTITY提供了唯一键。

例如,解析订单号即可获知该订单的下单渠道、支付渠道、业务类型与下单日期等,解析一个公民的身份号码可以直接获得该公民的部分基础信息,如出生日期、性别等。日报可以通过名称、城市和出版日期来识别。

(2)类名+类中唯一符号

当对象属性没办法形成真正唯一键时,另一种经常用到的解决方案是为每个实例附加一个在类中唯一的符号(如一个数字或字符串)。一旦这个ID符号被创建并存储为ENTITY的一个属性,必须将它指定为不可变的。它必须永远不变,即使开发系统无法直接强制这条规则。例如,当对象被扁平化到数据库中或从数据库中重新创建时,ID属性应该保持不变。有时可以利用技术框架来实现此目的,但如果没有这样的框架,就需要通过工程纪律来约束。

(3)系统自动生成ID

一些实体只要求身份标识具有唯一性即可,可以使用自动增长的Long类型、随机数、UUID或GUID。这样的身份标识并无任何业务含义。
ID通常是由系统自动生成的。生成算法必须确保ID在系统中是唯一的。在并行处理系统和分布式系统中,这可能是一个难题。生成这种ID的技术超出了本书的范围。这里的目的是指出何时需要考虑这些问题,以便使开发人员能够意识到有一个问题等待他们去解决,并知道如何将注意力集中到关键问题上。关键是要认识到标识问题取决于模型的特定方面。通常,要想找到解决标识问题的方法,必须对领域进行仔细的研究。

3.2.2 标识的设计

在设计实体的身份标识时,通常可以将身份标识的类型分为两种类型:通用类型与领域类型。

通用类型的ID值没有业务含义,采用了一些常用的技术手段来满足其唯一性,例如基于随机数的标识、数据库自增长的标识、根据机器MAC地址和时间戳生成的标识等。

根据ID的共同特征,可以设计一系列接口

//通用Identity接口
public interface Identity<T> implements Serializable{
	T value();
}
//随机数的身份接口
public interface RandomIdentity<T> implements Identity<T>{
	T next();
}
//UUID
public class UUIDIdentity extends RandomIdentity<T> {
	private String value;
	@Override
	public String next(){
		return UUID.randomUUID().toString();
	}
	@Override
	public String value(){
		return value;
	}
}

领域类型的身份标识通常与各个限界上下文的实体对象有关,例如为Employee定义EmployeeId类型,为Order定义OrderId类型。在定义领域类型的身份标识时,可以选择恰当的通用类型身份标识作为父类,然后在自身类的定义中封装生成身份标识的领域逻辑。

public class EmployeeId implements RandomIdentity<String> {
}

由于ID自身包含了组装ID值的业务逻辑,因而建议将其定义为值对象,保持值的不变性,同时提供身份标识的常用方法,隐藏生成身份标识值的细节,以便应对未来可能的变化。

同时,实体ID不管被定义为通用类型还是领域类型,都是领域驱动的设计结果。选择何种类型,取决于业务功能的要求。如果每个实体的身份标识都定义为自定义的ID类,一旦产生跨限界上下文之间对实体(实则是对聚合的根实体)ID的引用,就可能因为自定义的ID类型产生两个限界上下文之间不必要的耦合。

建议是将实体类自身的ID定义为ID类,而将它引用的别的实体ID定义为语言的基本类型。

public class Order extends Entity<OrderId> {
	private String customerId;
}

public class CustomerId extends RandomIdentity<String> {
	public static CustomerId of(String customerId){
		return new CustomerId(customerId);
	}
}

(2)乐观并发

为了避免多个用户同时修改同一个对象的状态,每次修改聚合内部的状态时,根实体的版本号应该要增加。

public class Group extends Entity {
	private GroupId groupId;
	private String name;
	private String desc;
	private Set<GroupMember> groupMembers;
}
public class GroupMember extends Entity {
	private GroupMemberId GroupMemberId;
	private String name;
	private GroupMemberType type;
	private int ordering;
}

以上面的例子为例,当修改根实体Group 的直接属性时,版本号自动增加。这没有问题!
但是,当修改根实体Group 的下级GroupMember 的属性时,比如说排序ordering时,Group的版本号应该增加吗?

不应该增加!
虽然我们可以将排序的方法放在根实体来操作,进而改变根实体的版本号,但这方法一旦调用,不管是不是修改了状态,版本号都要增加,并且还暴露了下级的实体模型。因此不建议增加根实体的版本号。

因此,直接改变下级实体本身的版本号就够了。

(三) 实体的建模

当对一个对象进行建模时,我们自然而然会考虑它的属性,而且考虑它的行为也显得非常重要。但ENTITY最基本的职责是确保连续性,以便使其行为更清楚且可预测。保持实体的简练是实现这一责任的关键。

不要将注意力集中在属性或行为上,应该摆脱这些细枝末节,抓住ENTITY对象定义的最基本特征,尤其是那些用于识别、查找或匹配对象的特征。

3.3.1 实体的属性

实体的属性用来说明主体的静态特征,并持有数据与状态。通常,我们会依据粒度的粗细将属性分为原子属性与组合属性。

原子属性就是定义为开发语言内建类型的属性,如整型、布尔型、字符串类型等,表述了不可再分的属性概念。

组合属性则通过自定义类型来表现,可以封装高内聚的一系列属性,实则也体现了主体内嵌的领域概念。

public class Product extends Entity<ProductId> {
	private String name:
	private int quantity;
	private Category category;
	private Weight weight.
	private Volume volume :
	private Price price;
}
(1)原子属性和组合属性是否存在分界线?

例如,能否将category定义为String类型,将weight定义为double类型?又或者,能不能将name定义为Name类型,将quantity定义为Quantity类型?划定这条边界线的标准就是:该属性是否存在约束规则、组合因子或属于自己的领域行为。

先看约束规则。相较于产品的名称(name)属性而言,产品的类别(category)属性具有更强的约束性。

再看组合因子。判断属性是否不可再分,如重量(weight)与体积(volume)属性有着明显的特征:需要值与计数单位共同组合。

最后来看领域行为。多数静态语言不支持为内建类型扩展自定义行为,要为属性添加属于自己的领域行为,只能选择组合属性。

(2)实体应该具备哪些属性?

不要将注意力集中在属性或行为上,应该摆脱这些细枝末节,抓住ENTITY对象定义的最基本特征,尤其是那些用于识别、查找或匹配对象的特征。只添加那些对概念至关重要的行为和这些行为所必需的属性。

此外,应该将行为和属性转移到与核心实体关联的其他对象中——组合属性。这些关联对象中,有些可能是实体,有些可能是值对象,取决于该属性是否需要身份标识。实体往往通过协调其关联对象的操作来完成自己的职责。

(3) 实体与聚合根有什么区别?

聚合根一定是实体对象,但是并不是所有实体对象都是聚合根,同时聚合根还可以拥有其他子实体对象。聚合根的ID在整个软件系统中全局唯一,而其下的子实体对象的ID只需在单个聚合根下唯一即可。

//例如,OrderItem是聚合根Order下的子实体对象:
public class Orderltem {
    private Productld productld;
    private int count;
    private BigDecimal itemPrice;
}

可以看到,虽然OrderItem使用了ProductID作为ID,但是此时我们并没有享受ProductID的全局唯一性,事实上多个Order可以包含相同ProductID的OrderItem,也即多个订单可以包含相同的产品。

其实有时设计一个实体,如果其内部由多个部件组成,那么它既是一个实体,也是一个聚合的聚合根,两者身份一致;如果一个实体只是由基本字段属性组成,不引用其他子对象,那么这个实体就不是一个聚合根。

//当A只含基本字段属性时
public class A {
	private int id;
	private String name;
	private int size;
}
//当A中引用了B对象时
public class A {
	private int id;
	private String name;
	private int size;
	private B b;
}

实体与标识的关系非常类似于聚合与聚合根的关系,聚合根是聚合的标识。

在此例中,当A中引用了B对象,说明这个实体是一个组合体了。结构有两个层次,是不是具备做聚合根的潜力?如果A是聚合根,那么A就是整个聚合的标识;A的内部也有标识字段id,那么id可不可以作为整个聚合的标识呢?

实际上是的,只不过它是一个标识字段,业务意义不是非常明确,使用其实体类名作为标识更加通用,而id才可能是计算机世界内的标识。

例如,Car是一个汽车的聚合根,它代表整体概念,由轮子、车厢和发动机等组成。因为Car是一个抽象的整体,无法在汽车中找到一个代表Car的部件,怎么办?使用发动机部件代表它,那么Car可能就有两个聚合根,发动机作为其标识也是一个聚合根,而发动机作为标识的主要依据是其内部标识属性:发动机号。

在此,发动机号既是Car的标识,又是发动机的标识。

public class CarAggergates{
    private int engineeNo;
    private Enginee enginee;
    private CarEntity carEntity;
}
(4)实体怎么设计?

实体的设计不只要照顾到所处上下文,还要兼顾它被创建后的生命周期管理,实体的类名负责它在上下文中的定位,而实体的标识负责它被创建后的生命。

一个实体的设计有两个关键:实体命名和标识发现。

例如,如果垃圾分类中干垃圾和湿垃圾分离,可以取一个类名为“干垃圾”,标识是由其组成的属性合成的,如塑料袋两个、塑料杯三个等,这些就可以使用标识:干垃圾XXX。由于标识在时间上具有持续性,因此标识中加上时间标记是非常有效的,那么这袋干垃圾的标识为:干垃圾某年某月某日某时。

实体的设计比较难,也更容易被设计成一个无所不包的大内容实体。分解一个实体的功能也需要从外部和内部两个方面取考虑,实体不再是简单的一个数据表的数据传输对象,不再是DTO数据结构,它是带有复杂职责和数据的对象。

封装内部属性

当我们学会将实体的属性尽可能定义为组合属性时,就会在实体内部形成各自的抽象层次。每个抽象层次对应的类型都专注于做自己的事情,各司其职,依据各自持有的数据与状态以及和领域概念之间的黏度分配职责,实体类就能变得更加内聚,承担的职责也就更单一。

现实中的实体可能有几十、几百个属性。例如,一个机场的航班的运载信息包括进出港的旅客信息,行李信息,邮件信息,货物信息等。它包含:

  • 进站的旅客、行李、邮件、货物的运载量;
  • 出站的旅客、行李、邮件、货物的运载量;
  • 中转的行李、邮件、货物的运载量。
public class CarryLoad extends Entity<CarryLoadId> {
	private String region;
	private String originStation;
	private String destinationStation;
	private Integer legNo:
	private Integer inAdultSum;
	private Integer inChildSum;
	private Integer inBabiesSum;
	private Integer inDivertAdultSum;
	private Integer inDivertChildSum;
	private Integer inDivertBabiesSum;
	private Integer outAdultSum;
	private Integer outChildSum;
	private Integer outBabiesSum:
	private Integer outDivertAdultSum;
	private Integer outDivertChildSum;
	private Integer outDivertBabiesSum;
	private BigDecimal inBaggageWeightSum;
	private Integer inBaggageCount;
	private BigDecimal inMailWeightSum;
	private Integer inMailCount;
	private BigDecimal inCargoWeightSum;
	private Integer inCargoCount;
	private BigDecimal outBaggageWeightSum;
	private Integer outBaggageCount;
	private BigDecimal outMailWeightSum;
	private Integer outMailCount:
	private BigDecimal outCargoWeightSum:
	private Integer outCargoCount;
	private BigDecimal divertBaggageWeightSum.
}

CarryLoad实体类的定义好似一个没有文件夹的文件系统,所有属性都位于一个抽象层次,缺乏对信息的隐藏,形成了一个扁平的对象结构。倘若按照内聚的领域概念进行封装,就能建立不同的抽象层次,有利于信息的隐藏和领域逻辑的复用。

public class CarryLoad extends Entity<CarryLoadId>{
	private String region;
	private CityPair cityPair:
	private Integer legNo;
	private PassengerLoad inPassengerLoad;
	private BaggageLoad inBaggageLoad;
	private MailLoad inMailload;
	private CargoLoad inCargoload:
	private PassengerLoad outPassengerLoad;
	private BaggageLoad outBaggageLoad;
	private Mailload outMailload;
	private CargoLoad outCargoload;
	private BaggageLoad divertBaggageLoad;
	private MailLoad divertMailLoad;
	private CargoLoad divertCargoLoad;
}

3.2 实体的行为

实体拥有领域行为,可以更好地说明其作为主体的动态特征。一个不具备动态特征的对象,是一个哑对象,一个“蠢”对象。这样的对象明明坐拥宝山(自己的属性)而不自知,还去求助他人操作自己的状态,着实有些“愚蠢”。为实体定义表达领域行为的方法,与前面讲到组合属性需要封装自己的领域行为是一脉相承的,都是“职责分治”设计思想的体现。

根据不同的行为特征,将实体拥有的领域行为分为:

  • 变更状态的领域行为;
  • 自给自足的领域行为;
  • 互为协作的领域行为。
(1)变更状态的领域行为

实体对象的状态由属性持有。与值对象不同,实体对象允许调用者更改其状态。许多语言都支持通过get与set访问器(或类似的语法糖)访问状态,这实际上是技术因素干扰着领域模型的设计。

领域驱动设计认为,由业务代码组成的实现模型是领域模型的一部分,业务代码中的类名、方法名应从业务角度表达领域逻辑。

public class Product extends Entity<ProductId> {
    public void changePriceTo(Price newPrice) {
        if (!this.price.sameCurrency(newPrice)) {
            throw new CurrencyException("Cannot change the price of this product to a different currency");            
        }
        this.sellingPrice = newPrice;
    }
}

这时的领域行为不再是一个简单的设置操作,它蕴含了领域逻辑。方法名也传递了业务知识,突破了set访问器的范畴,成了实体类拥有的领域行为,也满足了信息专家模式的要求,形成了对象之间行为的协作。

(2)自给自足的领域行为

自给自足意味着实体对象只操作了自己的属性,不外求于别的对象。这种领域行为最容易管理,因为它不会和别的实体对象产生依赖。即使实现逻辑发生了变化,只要定义好的接口无须调整,就不会将变化传递出去。

变更状态的领域行为由于要改变实体的状态,往往会产生副作用。

自给自足的领域行为则不同,主要对实体已有的属性值包括调用该实体组合属性定义的方法返回的值进行计算,返回调用者希望获得的结果。

//订单结算
public class OrderSettlement extends Entity<OrderSettlementId> {

    private Integer payNumber;
    //订单结算的总额
    private Money payAmount;
    private List<Payment> payments;

    public Money totalAmount() {
        if (payNumber == payments.size()) {
            if (!payAmount.equals(totalPayAmount())) {
                throw new OrderSettlementException("Error with calculating total pricefor Order settlement.");
            }
        }
        return payAmount;
    }

    private Money totalPayAmount() {
        Money totalAmount = new Money(0);
        for (Payment payment : payments) {
            totalAmount = totalAmount.add(payment.getPayAmount());
        }
        return totalAmount;
    }
}

例如,一个订单结算的类进行自身总金额的计算就属于自给自足的领域行为。

该领域行为并不复杂,但充分体现了行为的自给自足。整个方法仅操作了订单结算实体自己拥有的属性,包括payNumber、payAmount和payments。

(3)互为协作的领域行为

实体不可能都做到自给自足,有时也需要调用者提供必要的信息。这些信息往往通过方法参数传入,这就形成了领域对象之间互为协作的领域行为。
例如,要计算贸易订单实际应缴的税额,分为以下步骤:
(1)首先应该获得该贸易订单的纳税额度。这个纳税额度等于订单所属的纳税调节额度汇总值减去手动调节纳税额度值。
(2)获得的纳税额度再乘以贸易订单的总金额,就是贸易订单实际应缴的税额。

贸易订单的纳税调节为另一个实体对象TaxAdjustment。

一个贸易订单存在多个纳税调节,因此可引入一个容器对象TaxAdjustments。该对象本质上是一个领域服务,提供了计算纳税调节额度汇总值和手动调节纳税额度值的方法:

//纳税调节的集合
public class TaxAdjustments {
    private List<TaxAdjustment> adjustments;

    public double calculateTotalAdjustment() {
        double total = 0;
        for (TaxAdjustment adjustment : adjustments) {
            total += adjustment.getAmount();
        }
        return total;
    }

    public double calculateManualAdjustment() {
        double total = 0;
        for (TaxAdjustment adjustment : adjustments) {
            if (adjustment.isManual()) {
                total += adjustment.getAmount();
            }
        }
        return total;
    }
}
//纳税调节
public class TaxAdjustment {
    private double amount;
    private boolean manual;

    public TaxAdjustment(double amount, boolean manual) {
        this.amount = amount;
        this.manual = manual;
    }

    public double getAmount() {
        return amount;
    }

    public boolean isManual() {
        return manual;
    }
}
//贸易订单
public class TradeOrder {
    private double totalAmount;
    private TaxAdjustments taxAdjustments;

    public TradeOrder(double totalAmount, TaxAdjustments taxAdjustments) {
        this.totalAmount = totalAmount;
        this.taxAdjustments = taxAdjustments;
    }

    public double calculateTaxAmount() {
        double taxAdjustmentTotal = taxAdjustments.calculateTotalAdjustment();
        double manualAdjustment = taxAdjustments.calculateManualAdjustment();
        double taxBase = taxAdjustmentTotal - manualAdjustment;
        return taxBase * totalAmount;
    }
}

举例:

一般纳税人企业月销售额为1000万,增值部分为300万,在进项和销项税率一致的情况下,当月应缴纳的增值税为:300万*13%=39万。

还有一种特殊的领域行为,就是针对实体包括值对象进行“增删改查”,即对应为增加、删除、修改和查询这4个操作,它们负责管理对象的生命周期。领域驱动设计将这些行为分配给了专门的资源库对象,实体无须承担“增删改查”的职责。

实体拥有的变更状态的领域行为,修改的只是对象的内存状态,与持久化无关。

除了“增删改查”,创建行为也是对象生命周期管理的一部分,代表了对象在内存中从无到有的实例化。创建行为本由实体的构造函数履行,但当创建的行为逻辑较为复杂,又或者存在变化,就可以引入工厂类或工厂方法来封装实体的创建逻辑。无论是创建,还是增删改查,都需要结合聚合边界来管理实体的生命周期。

(四)值对象

值对象是没有唯一标识的对象,是一堆数据值的容器。

值对象(value object)通常作为实体的属性,也就是亚里士多德提到的分量、性质、关系、场所、时间、位置/姿态等范畴。

“当我们只关心一个模型元素的属性时,应把它归类为值对象。我们应该使这个模型元素能够表示出其属性的意义,并为它提供相关功能。值对象应该是不可变的。不要为它分配任何标识,而且不要把它设计成像实体那么复杂。”

1.值对象与实体有什么区别?

第一个判断依据是看业务的参与者对它的相等判断是依据值还是依据身份标识。——前者是值对象,后者是实体。

第二个判断依据是确定对象的属性值是否会发生变化,如果变化了,究竟是产生一个完全不同的对象,还是维持相同的身份标识。——前者是值对象,后者是实体。

最后一个判断依据是生命周期的管理。值对象没有身份标识,意味着无须管理其生命周期。从对象的角度看,它可以随时被创建或被销毁,甚至也可以被随意克隆用到不同的业务场景。实体则不同,在创建之后,系统就需要负责跟踪它状态的变化情况,直到它被删除。

实体与值对象的本质区别在于是否拥有唯一的身份标识。因为实体拥有身份标识,资源库才能管理和控制它的生命周期;因为值对象没有身份标识,就可以不用考虑值对象的生命周期,可以随时创建、随时销毁一个值对象,无须跟踪它的状态变更。值对象缺乏身份标识,在领域设计模型中,往往作为实体的附庸,表达实体的属性。

2.值对象(VO)与数据传输对象(DTO)有什么区别?

值对象中的数据值一旦被构建,就不能改变,这是不变性的特性。

而DTO没有这种约束,这容易导致DTO传输过程中不断添加、修改各种字段。

DTO变成一个装载数据的可变长度的容器,虽然给编程带来了方便,但是将可变性带到代码的各个地方,最后DTO进数据库存储时,才发现数据并不是原来想象的那样,至于在哪个环节修改了,就需要不断地跟踪,这种跟踪在复杂软件中也非常复杂。

值对象的不变性克服了DTO的这种缺点,如果希望改变其中的值,可重新构建一个新的值对象,这样有别于原来的对象,也可以使用克隆模型克隆(clone)原来的一些数据,甚至有很多框架支持对象之间的数据克隆。

3.如何保证值对象的不变性?

考虑到值对象只需关注值的特点,领域驱动设计建议尽量将值对象设计为不变类。若能保证值对象的不变性,就可以减少并发控制的成本,因为一个不变的类是线程安全的。

为了保证它的不变性,需要施加一些约束。Brian Goetz等人确定了不变类定义需满足的几个条件:

  • 对象创建以后其状态就不能修改;
  • 对象的所有字段都是final类型;
  • 对象是正确创建的(创建期间没有this引用溢出)。
@Immutable
public final class Money {
    private final double faceValue;
    private final Currency currency;

    public Money() {
        this(0d, Currency.RMB);
    }

    public Money(double value, Currency currency) {
        this.faceValue = value;
        this.currency = currency;
    }

    public Money add(Money toAdd) {
        if (!currency.equals(toAdd.getCurrency())) {
            throw new NonMatchingCurrencyException("不能添加不同的币种");
        }
        return new Money(faceValue + toAdd.getFaceValue(), currency);
    }

    public Money minus(Money toMinus) {
        if (!currency.equals(toMinus.getCurrency())) {
            throw new NonMatchingCurrencyException("不能减少不同的币种");
        }
        return new Money(faceValue - toMinus.getFaceValue(), currency);
    }
}

显然,既要保证对象的不变性,又要满足更新状态的需求,就需要用一个保存了新状态的实例来“替换”原有的不可变对象。

这种方式看起来会导致大量对象被创建,从而占用不必要的内存空间,影响程序的性能,但事实上,由于值对象往往比较小,内存分配的开销并没有想象中的大。由于不可变对象本身是线程安全的,无须加锁或者提供保护性副本,因此它在并发编程中反而具有性能优势。

4.值对象的领域行为怎么设计?

值对象的名称容易让人误会它只该拥有值,不应拥有领域行为。实际上,只要采用了对象建模范式,无论实体对象还是值对象,都需要遵循面向对象设计的基本原则,如信息专家模式,将操作自身数据的行为分配给它。

值对象拥有的往往是“自给自足的领域行为”。这些领域行为能够让值对象的表现能力变得更加丰富,更加智能。它们通常为值对象提供如下能力:

  • 自我验证;
  • 自我组合;
  • 自我运算。
(1)自我验证

当一个值对象拥有自我验证的能力时,拥有和操作值对象的实体类就会变得轻松许多。否则,实体类就可能充斥大量的验证代码,干扰了读者对主要领域逻辑的理解。

所谓“验证”,就是验证设置给值对象的外部数据是否合法。

若属性值与其生命周期有关,就需要在创建该值对象时进行验证。

验证逻辑是构造函数的一部分,可以是常规验证,如非空判断,也可能包含业务规则,如满足业务条件的取值范围、类型等。

倘若验证未通过,一般需要抛出表达业务含义的自定义异常。这些自定义异常皆派生自领域层的异常超类DomainException。

领域层的异常层超类为DomainException,北向网关应用层的异常层超类为ApplicationException,南向网关层不需要考虑自定义异常,因为它的实现代码抛出的异常属于访问外部资源的基础设施框架。

为了让应用服务告知远程服务调用者究竟是什么样的错误导致异常抛出,可以分别为应用层定义如下3种异常子类,均派生自ApplicationException类型:

  • ApplicationDomainException,由领域逻辑错误导致的异常;
  • ApplicationValidationException,由输入参数验证错误导致的异常;
  • ApplicationInfrastructureException,由基础设施访问错误导致的异常。
(2)自我组合

值对象往往牵涉对数据值的运算。为了更好地表达其运算能力,可定义相同类型值对象的组合运算方法,使得值对象具备自我组合能力。

引入组合方法既可以保证值对象的不变性,避免组合操作直接对状态进行修改,又是对组合逻辑的封装与验证,避免引入与错误对象的组合。

例如,Money值对象的add()与minus()方法验证了不同货币的错误场景,避免了直接计算两种不同货币的Money。注意,Money类的组合方法并没有妄求对货币进行汇率换算,因为汇率计算牵涉到对外部汇率服务的调用,不符合值对象领域行为“自给自足”的特性。

(3)自我运算

自我运算是根据业务规则对属性值进行运算的行为。根据需要,参与运算的值也可以通过参数传入。例如,Location值对象拥有longitude与latitude属性值,只需再提供另一个地理位置,就可计算两个地理位置之间的直线距离:

一个拥有合理领域行为的值对象可以分摊担在实体身上的重任,让实体的职责变得更单一。由于无须管理值对象的生命周期,因此值对象可能被多个实体类调用,如Money、Address这样的值对象,可能会被多个限界上下文的领域模型调用,可考虑将它们定义在共享内核中,以便跨限界上下文的复用。此时,为值对象分配自给自足的领域行为就变得更有必要,因为它能避免零散的领域逻辑在多个限界上下文的实体类中泛滥,体现了良好的职责边界。

5.值对象怎么构建?

值对象的构建一般是由聚合根实体负责的,任何聚合外界需要使用聚合内的信息,都需要通过聚合根访问。

聚合根不能将自己内部的对象直接奉献给外部,因为一旦被外界修改了,自己都不知道,就可能造成内部逻辑的不一致,就像有外键关联的两个数据表,一个表修改了数据,而另外一个没有修改,这种情况是可怕的,不过因为外键约束的存在,数据库会进行这两个表的原子更新,但是内存中的对象没有这样的技术机制,而需要通过专门的设计来保证,因此不将聚合内部的对象直接暴露给外界是基本原则,外界如果需要一些数据,可以根据聚合内对象构造一个值对象使用。

例如,如果一个类Product中属性多了,就需要归类为值对象。

public class Product {
	private String id;
	private String name;
	private String model;//型号
	private String Specifications;//规格
	private int length;//长
	private int width;//宽
	private int height;//高
}

Product中包含了长、宽、高等规则和型号,这些都是产品的各种参数,将其散落在Product,不但使它的代码显得冗长,还会造成主次混乱。

如果Product是一个聚合根实体,就需要在这里主要突出其聚合根的特性,参数等具体细节太多,会掩盖主要部分,那就使用值对象来封装这些参数值。

//商品规格
public class Productspec {
    private final String model;
    private final String Specifications;
    private final int length;
    private final int width;
    private final int height;
}

那么Product就会变得干净很多。

public class Product {
	private String id;
	private String name;
	private Productspec productspec ;//型号
}

这样设计的一个好处是,ProductSpec可以被很多个不同id值的Product共享,只要是同样规格的商品都可以共享一个ProductSpec实例。

同一个ProductSpec实例可被不同Product共享,如果ProductSpec是可变的,那么ProductSpec如果被修改了,就会影响所有的Product,为了避免这种直接对ProductSpec进行的黑客式修改,ProductSpec值对象被设计为不可改变(final关键字),如果需要修改,就通过Product来完成。

Product作为父对象,对自己的组成部件拥有生杀大权,如果Product没有提供修改ProductSpec的方法,那说明设计意图就是ProductSpec永远不能修改。

因为在商品管理上下文中,ProductSpec已经变成了Product的一个组成部分,所以它们拥有同样的生命周期,同生共死。

上面的代码还存在一点变化,这可能在意料之外的。Product中的name和productSpec 同样重要。虽然用String类型在一开始看来已经足够,但是在随后的迭代中,它将带来问题。围绕着name展开的领域逻辑有可能从Product模型中泄露出去,如下面的代码所示:

//客户端试图自己处理大小写问题
	String name = product.name();
	String capitalizedName = name.substring(0,1).toUpperCase()
	+ name.substring(1).toLowerCase();

当客户端意图对输入的商品名称进行处理时,客户端自己试图解决name的大小写问题。通过定义ProductName 类型,我们可以将与name有关的所有逻辑操作集中在一起。以上面的例子来说,ProductName 可以在初始化时对name进行格式化,而不用客户端自身来处理。

public class Product {
	private String id;
	private ProductName name;//产品名称
	private ProductSpec productspec ;//型号
}

Product是一个聚合根实体,ProductSpec是聚合边界内的一个部件对象,没有ProductSpec就没有Product,ProductSpec一但修改,等于修改了Product,当然必须征得Product自己同意,这是数据所有权的问题。

当Product在订单上下文中需要被使用时,就不能将Product作为实体了,因为商品本身不是关注的重点,也就是说,商品内部不是关注重点了,只有在商品管理上下文中,Product有哪些参数、有哪些规格才是被关注的,这些都是商品的内部属性。而在订单上下文,订单才是最被关注的重点,在这个场景下,关注的是购买了多少个商品,这时是从商品外部去指认它,只需要一个供指引的值就可以了,当然包含一些必要的关键属性,所以商品Product在订单上下文就变成了一个值对象:

public class Product {
	private final String id;
	private final String name;//产品名称
	...
}

这个值对象的内容可以由商品管理上下文中Product的build()方法来构建,也可以由订单上下文自己来构建,当然,关键是将唯一标识id作为值对象传输,这样在不同上下文中如果需要Product的更多信息,就可以通过id再次请求商品管理上下文,获得完整的Product对象,然后根据自己的上下文将信息裁剪成一个新的值对象。

6.如何用值对象重构项目?

为什么从值对象开始入手DDD,而不是实体呢?因为从值对象重构比较容易、轻量,而实体重构会冲击数据库表结构设计,会有聚合根等概念,相对来说是很大的变动。从不起眼的值对象开始小修小补,在尝试中学习进步,最后再重新设计实体,甚至聚合和有界上下文,总之,新的项目从有界上下文、聚合、实体和值对象依次开始,而重构老项目时倒过来进行更容易一些。

class BusinessService{
    public String decode(String accessToken){
        return API.call(accessToken).getResult("bussinessId");
    }
}

这里有个通用的业务服务,功能是获取token后解码获得某个业务结果。

这里decode()返回的结果是普通字符串的值,过于通用和基础,可以使用值对象封装它,再以业务名称命名:

class OrderId {
	public final String value;
	...
}

这个值对象的名称是OrderId,是订单的唯一标识,其内部值是一个普通字符串,这样重构原来的服务为:

class OrderService {
    public OrderId decode(String accessToken){
        return new OrderId(API.call(accessToken).getResult("orderId"));
    }
}

这样的代码就能很好地记录自己,代码才体现文档作用,否则就要抽象出很多技术名称和通用术语,然后再用文档说明这些技术名称和术语实现什么业务目的,与什么业务有关。

理,可以对方法的输入参数也进行封装。这里的accessToken是一个字符串类型,也是一个基本类型,当然accessToken是一个与权限有关的名词,属于权限领域,那么就建立一个专门的值对象:

class AccessToken {
	public final String value;
	...
}

这个AccessToken的类名与其变量名称一样,这也是值对象重构的简单之处,值对象就是含值的对象,与String等类型一样。

其实,String也是一个值对象,是字符串类型的值对象,但是字符串类型这个名称太通用,过于技术,如果代码中大量使用这种String类型,代码的业务特性不明显,阅读性不强。

class OrderService {
    public OrderId decode(AccessToken accessToken){
        return new OrderId(API.call(accessToken.value).getResult("orderId"));
    }
}

当将方法的输入参数和输出结果使用值对象重构以后,可能会有意想不到的视角和发现,比如,decode()方法放在OrderService中是否合适呢?

订单服务中应该放入直接与订单实体有关的功能,这里是订单Id的获取,不是直接与订单服务有关,而是与订单有关。

根据面向对象设计的原则,一个方法应该放在最贴近它所操作的数据的类中。

订单Id是订单中的唯一标识,那么这个decode()方法可以放入类OrderId中吗?还是放入输入对象AccessToken类型中呢?还是放在Order实体中呢?

这里碰到一个面向对象中的经典难题,当一个转换动作涉及两个对象时,就很难确定归类于哪个类型,这时候函数式编程思路就很有帮助。

分析:
其判断依据主要是该方法的主要功能细节是什么,涉及什么依赖资源,如果依赖资源多与权限领域有关,那么放入AccessToken,如果和订单标识有关,就可以放入OrderId中。

这里的decode()用于生成Order实体的唯一标识,好像与Order实体有关,但是要注意到:没有唯一标识就没有实体,目前还是处于实体构建之中,位于实体的外部。OrderId比订单更早,这里还没有订单Order什么事情,那么放入OrderId值对象中呢?

OrderId值对象中放入一个根据远程API生成的值,这可能造成OrderId和具体生成机制有依赖,如果使用其他API、本地数据库或UUID,创建职责和使用职责就会耦合在一个类中,因此放在OrderId中不合适,似乎应该放入OrderId的工厂或Builder生成器中。

由于decode()方法是一个将令牌(Token)转换为OrderId的普通方法,并不是一种复杂的创建过程,OrderId只是含有一个字符串值的值对象,Builder模式也派不上用场,那么看看可否放入AccessToken中:

class AccessToken{
	public final String value;
    public OrderId getOrderId(){
        return new OrderId(API.call(value).getResult("orderId"));
    }
}

使用上面的代码,最终不需要在服务类中提取业务标识值,唯一需要做的是在令牌实例上调用一个方法,这里体现了在类中封装行为以及数据。

由于值对象的不可变性,其内部属性在值对象创建以后就不能改变,因此值对象的行为方法肯定不是对其内部属性进行修改,而是根据各种应用场景上下文进行不同格式的输出。这里需要一个OrderId,因此AccessToken根据令牌转换得到一个OrderId结果,非常自然,符合值对象本身的特性。

7.值对象与标准类型的关系?

值对象便是建模度量和描述概念的最佳方式。
假如你的通用语言定义了一个PhoneNumber值对象,同时需要为每个PhoneNumber对象制定一个类型。“这个号码是家庭电话、移动电话、工作电话还是其他类型的电话号码?”不同类型的电话号码类型需要建模成一种类的层级关系吗?

为每一个类型创建一个类对于客户端使用来说是非常困难的。此时,你需要的是使用标准类型来描述不同类型的电话号码,比如Home、Mobile、Work或者Other。

状态模式需要创建一个抽象基类,其中包含了需要支持的所有行为,然后为每个实际状态创建一个实体类来覆盖抽象类的行为。在Java中,我们需要为抽象类和实际状态类分别创建一个类(通常是一个类文件)。不管你是否喜欢这种做法,这就是状态模式的工作方式。

public abstract class PhoneNumber {
}
public class HomePhoneNumber extends PhoneNumber {}
public class MobilePhoneNumber extends PhoneNumber {}
public class WorkPhoneNumber extends PhoneNumber {}
public class OtherPhoneNumber extends PhoneNumber {}

考虑一个表示多种成员类型的标准类型,在Java中可以使用枚举来表示该标准类型:

public class PhoneNumber {
    private String number;
    private PhoneType type;

    public PhoneNumber(String number, PhoneType type) {
        this.number = number;
        this.type = type;
    }

    public String getNumber() {
        return number;
    }

    public PhoneType getType() {
        return type;
    }

    public enum PhoneType {
        HOME,
        MOBILE,
        WORK,
        OTHER
    }
}

枚举提供了一组有限数量的值对象,它是非常轻量的,并且无副作用。

8.值对象的复制与共享有什么区别?

值对象没有标识,用来描述领域的某个方面,具有统一的概念。我们只关心值对象是什么,不关心值对象是谁。
怎么使用值对象?
类似于两个人同名不意味着他们是同一个人,但是他们的名字可以是互换的Name对象,把Name对象复制给对方,或者共享同一份Name对象。直到某个人改了名字,结果两个人的名字都改变了。
为了防止这种情况,值对象必须设计成不变的
在这里插入图片描述
当然,在分布式系统中复制对象更方便,而共享同一对象意味着要进行多次通信。

值对象设计成可变的情况

  • 值频繁改变
  • 对象创建开销很大
  • 替换对象会打乱集群
  • 值对象共享的情况不多

5.服务

有时对象不是一系列事物名词,而是一系列操作动词,我们把这些操作称之为服务
一些领域操作不适合放在实体和值对象里,但是我们又是面向对象建模的,所以Service作为接口在模型中独立存在。
Service的名称应该活动,操作方法应该用“通用语言”来命名,参数和结果应该是领域对象。
Service满足以下3个特征:

  • 与领域操作相关,并不属于实体或值对象
  • 接口是根据领域模型的其他元素定义的
  • 操作是无状态的,即没有历史记录

Service有应用层、领域层和基础设施层三种。

  • 应用层Service 与访问相关 例如,应用程序把银行交易转换成电子表格供分析
  • 领域层Service 业务层面 例如,处理资金转账相应的借方或贷方
  • 基础设施层Service 纯技术层面 例如,封装电子邮件通知客户
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
领域驱动设计(Domain-Driven Design,简称DDD)是一种软件开发方法论,旨在解决复杂业务领域的软件开发问题。它强调将业务领域的知识和概念直接融入到软件设计和开发中,以实现更好的业务价和可维护性。 在C#中实施DDD时,可以采用以下几个关键概念和技术: 1. 领域模型(Domain Model):领域模型是DDD的核心概念,它是对业务领域的抽象和建模。在C#中,可以使用类和对象来表示领域模型,通过定义实体(Entity)、对象(Value Object)、聚合根(Aggregate Root)等概念来描述业务领域中的实体和关系。 2. 领域驱动设计的分层架构:DDD通常采用分层架构来组织代码。常见的分层包括用户界面层(UI)、应用服务层(Application Service)、领域层(Domain Layer)、基础设施层(Infrastructure Layer)等。每一层都有不同的职责和关注点,通过良好的分层设计可以实现代码的可维护性和可测试性。 3. 聚合根和聚合:聚合根是DDD中的一个重要概念,它是一组相关对象的根实体,通过聚合根可以保证一致性和边界。在C#中,可以使用类来表示聚合根,通过定义聚合根的行为和关联关系来实现业务逻辑。 4. 领域事件(Domain Event):领域事件是DDD中用于描述领域中发生的重要事情的概念。在C#中,可以使用事件(Event)或委托(Delegate)来表示领域事件,并通过事件驱动的方式来处理领域事件。 5. 仓储(Repository):仓储是用于持久化和检索领域对象的接口或类。在C#中,可以使用接口和实现类来定义仓储,并通过依赖注入等方式将仓储注入到其他类中。 6. 领域服务(Domain Service):领域服务是一种用于处理领域逻辑的服务。在C#中,可以使用类和方法来表示领域服务,并将其注入到其他类中使用。 以上是DDD领域驱动设计在C#中的一些关键概念和技术。通过合理运用这些概念和技术,可以更好地实现复杂业务领域的软件开发。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值