DDD 核心概念与 Domain primitive

Posted silence、J

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了DDD 核心概念与 Domain primitive相关的知识,希望对你有一定的参考价值。

一、什么是DDD?

领域驱动设计(Domain-Driven Design 简称DDD),是一套成熟的理论方法来指导中台领域建模以及微服务拆分和设计,聚焦于“如何在复杂业务场景下设计软件”。

2003年埃里克·埃文斯(ErjcEvans)出版了《领域驱动设计》这本书后, DDD诞生。DDD的核心思想是从业务视角出发,根据限界上下文边界划分业务的领域边界,定义领域模型,确定业务边界。在微服务落地时,建立业务领域模型与微服务代码模型的映射关系,从而保证业务架构与微服务系统架构的—致性。但DDD提出后在软件开发领域一直都是“雷声大雨点小” ,直到MartjnFowler提出微服务架构后,DDD才真正 迎来了自己的时代。

DDD首先从业务领域入手,划分业务领域边界,采用事件风暴工作坊方法,分析并提取业务场景中的实体、值对象、聚合根、聚合、领域事件等领域对象,根据限界上下文边界构建领域模型,将领域模型作为微服务设计的输入,进而完成微服务洋细设计。用DDD方法设计出来的微服务,业务和应用边界非常清晰,符合“高内聚,低耦合”的设计原则,可以轻松适应业务模型变化和微服务架构演进。
——《基于DDD和微服务的中台架构与实现》

微服务与DDD相辅相成,属于共生关系。这体现在两个方面:

  • 微服务提倡将应用进行服务化拆分,通过业务领域边界实现应用服务边界的划分。
  • DDD恰好提供了一种基于业务限界上下文边界来实现微服务“高内聚,低藕合”的服务构建方法。

正因如此,将两者合理搭配使用,研发组织可以轻松实现面向服务的设计,享受持续交付与架构演进。

DDD与微服务,乃至中台设计的结合,目前仍是—个非常新的领域。

二、DDD核心知识体系概念

DDD的核心知识有:领域、子域、核心子域、通用子域、 支撑子域、限界上下文、实体、值对象、聚合和聚合根、领域事件、领域服务、应用服务和分层架构等。

2.1 领域

DDD的领域就是这个边界内要解决的业务问题域。

2.2 子域

领域会被细分为不同的子域,可以根据子域自身的重要性和功能属性将它们划分为三类子域,分别是:核心子域、通用子域和支撑子域。

2.3 核心子域

在企业内决定产品或企业核心竞争力的功能子域是核心子域。

2.4 通用子域

那些没有太多 个性化的诉求,同时又会被多个子域重复使用的通用功能子域是通用子域。

2.5 支撑子域

是企业必需的,但它既不是决定产品或企业核心竞争力的功能,也不是被其他子域复用的通用功能,这类子域是支撑子域。

2.6 限界上下文

限界上下文就是在限定的上下文环境内,用来封装通用语言领域对象。保证领域内的—些术语、领域对象等有一个确切的含义的没有语义二义性的一个业务边界。

通用语言中的名词一般可以给领域对象命名,如商品、订单等,它们对应领域模型中的 实体对象。而动词则表示—个动作或领域事件,如商品已下单、订单支付等,它们对应领域模型中的 领域事件 或者 命令。

比如:商品在不同的阶段有不同的表达形式。商品在销售阶段是商品,这是它的原始含义。在销售阶段结束后,商品就进人了运输阶段,这时商品就变成了货物。可见,同样的一件商品,由于业务领域边界的不同,这些通用语言的术语就有了不同的含义。限界上下文就是用来定义这些通用语言的上下文边界的。这个边界既是业务领域的边界,也是微服务拆分的边界。

2.7 实体

实体一般对应业务对象,它具有相对丰富的业务属性和业务行为。
在代码模型中,实体的表现形式是实体类,这个类包含了实体的属性和方法。通过这些方法实现实体自身的业务行为和业务逻辑。DDD更强调面向对象的设计方法。这些实体类通常采用充血模型,与实体相关的所有业务逻辑都在实体类方法中实现,跨多个实体的领域逻辑则在领域服务中实现。实体以领域对象(DO)的形式存在,而与数据库字段映射的应该以持久化对象(PO)的形式存在,PO是贫血的。

DO不一定要与表结构对应,比如用户user与角色role两个持久化对象可生成权限实体,一个DO实体会对应两个持久化对象,这是一对多的场景。
再比如,有些场景为了避免数据库的联表查询,提升系统性能,会将客户信息customer和账户信息account两类数据保存到同—张数据库表中。客户和账户两个实体可根据需要从—个持久化对象中生成,这就是多对—的场景。

充血模型与贫血模型的关键差异:在充血模型中,业务逻辑都在领域实体对象中实现,实体本身不仅包含了属性,还包含了它的业务行为。DDD领域模型中实体是一个具有业务行为和逻辑的对象。而在贫血模型中领域对象大多只有setter和getter方法,业务逻辑统一放在业务逻辑层实现,而不是在领域对象中实现。

2.8 值对象

值对象本质是—个属性集合,主要完成对实体的状态和特征描述。是若干个基于描述目的“具有整体概念和不可修改的属性”。在应用运行时,我们主要关注这些属性集的“值”。如下图,传统数据模型中一般如右边在类中罗列地址相关属性,而在领域模型中地址相关属性被封装为地址“值对象”,人员实体持有这个地址的引用。

这样做在逻辑上显然更清晰。这样实现在表结构上有两种设计方法:

2.9 聚合

聚合在领域模型里是一个逻辑边界,它本身没有业务逻辑实现相关的代码。

实体和值对象都只是个体化的业务对象,它们所表现出来的是个体的行为和能力。在领域模型中我们需要一个这样的组织,将这些紧密关联的个体对象聚集在一起,按照组织内统—的业务规则共同完成特定的业务功能,因此就有了聚合的概念。
领域模型内的 实体 和 值对象 就类似这些组织中的个体,而能让实体和值对象协同工作的组织就是聚合。

比如,订单聚合就有自己内部的业务规则,在订单聚合内每次修改商品数据时,它们都必须符合订单聚合的业务规则:“订单总金额等于所有商品明细金额之和。”违反了这个业务规则,就会出现聚合数据不一致等诸多问题。

聚合是数据修改和持久化的基本单元。在传统数据模型中每一个实体都是对等的,在业务逻辑实现时,可以随意找到实体或数据库表完成数据修改, 但这类操作在DDD的聚合内是不被允许的!

2.10 聚合根

如果把聚合比作组织,那聚合根就是这个组织的负责人。
聚合根也称为根实体,但它 不仅是实体,还是聚合的管理者。

  • 聚合根是实体,作为实体,它拥有实体的业务属性和业务行为,可以在聚合根实现自身的业务逻辑。
  • 它作为聚合的管理者,在聚合内负责协调实体和值对象,按照固定的业务规 则,协同完成聚合共同的业务逻辑°
  • 它还是聚合对外的联络人和接口人,聚合之间以聚合根ID关联的方式接受聚合的外部任务和请求,在限界上下文内实现聚合之间的业务协同,聚合外部对象不能直接通过对象引用的方式访问聚合内的对象。比如,当你需要访问其他聚合的实体时,可以在应用服务中调用其他聚合的领域服务,将关联的聚合根ID作为服务参数,先访问聚合根,再通过聚合根导航到聚合内部实体。

2.11 领域事件

在领域建模时,除了命令和操作等业务行为以外,还有—类非常重要的事件,这类事件发生后通常会触发进一步的业务操作,比如:“如果发生……,则……”,当做完……时,通知……”,发生……时,则……”等。在DDD中这类事件被称为领域事件(DomainEvent)。

举例来说,领域事件可以是业务流程的一个步骤,比如投保业务缴费完成后,触发投保单转保单的动作;
也可以是定时批处理过程中发生的事件,比如批处理生成季缴保费通知单,触发缴费邮件通知操作;
还可以是一个事件发生后触发的后续动作,比如密码连续输错三次,触发锁定账户的动作。

2.12 分层架构

  • **用户接口层:**面向前端用户提供服务和数据适配。这一层聚集了接口和数据适配相关的功能。
  • **应用层:**实现服务组合和编排,主要适应业务流程快速变化的需求。这一层聚集了应用服务和事件订阅相关的功能。
  • **领域层:**实现领域模型的核心业务逻辑。这—层聚集了领域模型的聚合、聚合根、 实体、值对象、领域服务和事件等领域对象,通过各领域对象的协同利组合形成领域模型的核心业务能力。
  • **基础层:**它贯穿所有层,为各层提供基础资源服务。这—层聚集了各种底层资源相关的服务和能力。常见的功能是完成实体的数据库持久化。

领域模型的业务逻辑从领域层、应用层到用户接口层逐层组合和封装,对外提供灵活的服务。既实现了各层的分工和解耦,又实现了各层的协作。

三、Domain Primitive

实体与值对象,是领域模型的基础单元。
Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object 。

下面从代码示例上来分析DP的好处。

3.1 案例

我们先看一个简单的例子,这个 case 的业务逻辑如下:

一个新应用在全国通过 地推业务员 做推广,需要做一个用户注册系统,同时希望在用户注册后能够通过用户电话(先假设仅限座机)的地域(区号)对业务员发奖金。

先不要去纠结这个根据用户电话去发奖金的业务逻辑是否合理,也先不要去管用户是否应该在注册时和业务员做绑定,这里我们看的主要还是如何更加合理的去实现这个逻辑。一个简单的用户和用户注册的代码实现如下:

public class User 
    Long userId;
    String name;
    String phone;
    String address;
    Long repId;


// 注册服务
public class RegistrationServiceImpl implements RegistrationService 

    // 销售员仓储
    private SalesRepRepository salesRepRepo;
    // 用户仓储
    private UserRepository userRepo;

    public User register(String name, String phone, String address) 
      throws ValidationException 
        // 校验逻辑
        if (name == null || name.length() == 0) 
            throw new ValidationException("name");
        
        if (phone == null || !isValidPhoneNumber(phone)) 
            throw new ValidationException("phone");
        
        // 此处省略address的校验逻辑

        // 取电话号里的区号,然后通过区号找到区域内的SalesRep
        String areaCode = null;
        String[] areas = new String[]"0571", "021", "010";
        for (int i = 0; i < phone.length(); i++) 
            String prefix = phone.substring(0, i);
            if (Arrays.asList(areas).contains(prefix)) 
                areaCode = prefix;
                break;
            
        
        SalesRep rep = salesRepRepo.findRep(areaCode);

        // 最后创建用户,落盘,然后返回
        User user = new User();
        user.name = name;
        user.phone = phone;
        user.address = address;
        if (rep != null) 
            user.repId = rep.repId;
        

        return userRepo.save(user);
    

    private boolean isValidPhoneNumber(String phone) 
        String pattern = "^0[1-9]2,3-?\\\\d8$";
        return phone.matches(pattern);
    

我们日常绝大部分代码和模型其实都跟这个是类似的,这属于脚本式编程,乍一看貌似没问题,但我们再深入一步,从以下四个维度去分析一下:接口的清晰度(可阅读性)、数据验证和错误处理、业务逻辑代码的清晰度、和可测试性。

3.1.1 问题1:接口清晰度

在Java代码中,对于一个方法来说所有的参数名在编译时丢失,留下的仅仅是一个参数类型的列表,所以我们重新看一下以上的接口定义,其实在运行时仅仅是:

User register(String, String, String);

所以以下的代码是一段编译器完全不会报错的,很难通过看代码就能发现的 bug :

service.register("张三", "河北省沧州市吴桥县黄河路二号院", "0571-12345678");

这三个入参不管顺序如何,在编译时都不会报错,但这种 bug 会在运行时被发现。普通的 Code Review 也很难发现这种问题,很有可能是代码上线后才会被暴露出来。

另外一种常见的,特别是在查询服务中容易出现的例子如下:

User findByName(String name);
User findByPhone(String phone);
User findByNameAndPhone(String name, String phone);

由于入参都是 String 类型,不得不在方法名上面加上 ByXXX来区分,而 findByNameAndPhone同样也会陷入前面的入参顺序错误的问题,而且和前面的入参不同,这里参数顺序如果输错了,方法不会报错只会返回 null,而这种 bug 更加难被发现。

3.1.2 问题2:数据验证和错误处理

在前面这段数据校验代码:

if (phone == null || !isValidPhoneNumber(phone)) 
    throw new ValidationException("phone");

在日常编码中经常会出现,一般来说这种代码需要出现在方法的最前端,确保能够 fail-fast 。但是假设你有多个类似的接口和类似的入参,在每个方法里这段逻辑会被重复。而更严重的是如果未来我们要拓展电话号去包含手机时,很可能需要加入以下代码:

if (phone == null || !isValidPhoneNumber(phone) || !isValidCellNumber(phone)) 
    throw new ValidationException("phone");

如果你有很多个地方用到了 phone 这个入参,但是有个地方忘记修改了,会造成 bug 。这是一个 DRY 原则(Don’t Repeat Yourself)被违背时经常会发生的问题。

如果有个新的需求,需要把入参错误的原因返回,那么这段代码就变得更加复杂:

if (phone == null) 
    throw new ValidationException("phone不能为空");
 else if (!isValidPhoneNumber(phone)) 
    throw new ValidationException("phone格式错误");

若代码里充斥着大量的类似代码块时,会极大提高维护成本。

最后,在这个业务方法里,会(隐性或显性的)抛 ValidationException,所以需要外部调用方去try/catch,而业务逻辑异常和数据校验异常被混在了一起,是否是合理的?

在传统Java架构里有几个办法能够去解决一部分问题,常见的如BeanValidation注解或ValidationUtils类,比如:

// Use Bean Validation
User registerWithBeanValidation(
  @NotNull @NotBlank String name,
  @NotNull @Pattern(regexp = "^0?[1-9]2,3-?\\\\d8$") String phone,
  @NotNull String address
);

// Use ValidationUtils:
public User registerWithUtils(String name, String phone, String address) 
    ValidationUtils.validateName(name); // throws ValidationException
    ValidationUtils.validatePhone(phone);
    ValidationUtils.validateAddress(address);
    ...

但这几个传统的方法同样有问题:
BeanValidation:

  • 通常只能解决简单的校验逻辑,复杂的校验逻辑一样要写代码实现定制校验器
  • 在添加了新校验逻辑时,同样会出现在某些地方忘记添加一个注解的情况,DRY原则还是会被违背

ValidationUtils类:

  • 当大量的校验逻辑集中在一个类里之后,违背了Single Responsibility单一性原则,导致代码混乱和不可维护
  • 业务异常和校验异常还是会混杂

3.1.3 问题3 - 业务代码的清晰度

在这段代码里:

String areaCode = null;
String[] areas = new String[]"0571", "021", "010";
for (int i = 0; i < phone.length(); i++) 
    String prefix = phone.substring(0, i);
    if (Arrays.asList(areas).contains(prefix)) 
        areaCode = prefix;
        break;
    

SalesRep rep = salesRepRepo.findRep(areaCode);

出现了另外一种常见的情况,那就是从一些入参里(phone)抽取一部分数据(prefix),然后调用一个外部依赖获取更多的数据(findRep),然后通常从新的数据中再抽取部分数据用作其他的作用。

这种代码通常被称作**“胶水代码”**,其本质是由于外部依赖的服务的入参并不符合我们原始的入参导致的。比如,如果SalesRepRepository包含一个findRepByPhone的方法,则上面大部分的代码都不必要了。
所以,一个常见的办法是将这段代码抽离出来,变成独立的一个或多个方法:

private static String findAreaCode(String phone) 
    for (int i = 0; i < phone.length(); i++) 
        String prefix = phone.substring(0, i);
        if (isAreaCode(prefix)) 
            return prefix;
        
    
    return null;


private static boolean isAreaCode(String prefix) 
    String[] areas = new String[]"0571", "021";
    return Arrays.asList(areas).contains(prefix);

然后原始代码变为:

String areaCode = findAreaCode(phone);
SalesRep rep = salesRepRepo.findRep(areaCode);

而为了复用以上的方法,可能会抽离出一个静态工具类 PhoneUtils。但是这里要思考的是,静态工具类是否是最好的实现方式呢?当你的项目里充斥着大量的静态工具类,业务代码散在多个文件当中时,你是否还能找到核心的业务逻辑呢?

3.1.4 问题4 - 可测试性

假如一个方法有 N 个参数,每个参数有 M 个校验逻辑,至少要有 N * M 个 TC (Test Case)。

如果这时候在该方法中加入一个新的入参字段 fax ,即使 faxphone 的校验逻辑完全一致,为了保证 TC 覆盖率,也一样需要 M 个新的 TC 。

而假设有 P 个方法中都用到了 phone这个字段,这 P 个方法都需要对该字段进行测试,也就是说整体需要:
**P * N * M **个测试用例才能完全覆盖所有数据验证的问题,在日常项目中,这个测试的成本非常之高,导致大量的代码没被覆盖到。而没被测试覆盖到的代码才是最有可能出现问题的地方。

3.1.5 解决方案

再回顾一下原来的业务描述:
一个新应用在全国通过 地推业务员 做推广,需要做一个用户注册系统,在用户注册后能够通过用户电话号的区号对业务员发奖金。

分析后发现,地推业务员、用户本身自带 ID 属性,属于 Entity(实体),而注册系统属于 Application Service(应用服务),这几个概念已经有存在。但是发现电话号这个概念却完全被隐藏到了代码之中。考虑一下,取电话号的区号的逻辑是否属于用户(用户的区号?)?是否属于注册服务(注册的区号?)?如果都不是很贴切,那就说明这个逻辑应该属于一个独立的概念。

所以这里引入第一个原则:
Make Implicit Concepts Explicit 将隐性的概念显性化

原来电话号仅仅是用户的一个参数,属于隐形概念,但实际上电话号的区号才是真正的业务逻辑,而我们需要将电话号的概念显性化,通过写一个Value Object 值对象:

public class PhoneNumber 
  
    private final String number;
    public String getNumber() 
        return number;
    

    public PhoneNumber(String number) 
        if (number == null) 
            throw new ValidationException("number不能为空");
         else if (isValid(number)) 
            throw new ValidationException("number格式错误");
        
        this.number = number;
    

    public String getAreaCode() 
        for (int i = 0; i < number.length(); i++) 
            String prefix = number.substring(0, i);
            if (isAreaCode(prefix)) 
                return prefix;
            
        
        return null;
    

    private static boolean isAreaCode(String prefix) 
        String[] areas = new String[]"0571", "021", "010";
        return Arrays.asList(areas).contains(prefix);
    

    public static boolean isValid(String number) 
        String pattern = "^0?[1-9]2,3-?\\\\d8$";
        return number.matches(pattern);
    


这里面有几个很重要的元素:

  1. 通过 private final String number 确保 PhoneNumber 是一个(Immutable)Value Object。(一般来说 VO 都是 Immutable 的,这里只是重点强调一下)
  2. 校验逻辑都放在了 constructor 里面,确保只要 PhoneNumber类被创建出来后,一定是校验通过的。
  3. 之前的 findAreaCode 方法变成了 PhoneNumber类里的 getAreaCode,突出了 areaCodePhoneNumber 的一个计算属性。

这样做完之后,我们发现把 PhoneNumber 显性化之后,其实是生成了一个 Type(数据类型)和一个 Class(类):

  • Type 指我们在今后的代码里可以通过 PhoneNumber 去显性的标识电话号这个概念
  • Class 指我们可以把所有跟电话号相关的逻辑完整的收集到一个文件里

这两个概念加起来,构造成了 Domain Primitive

3.1.6 使用DP重构后效果

public class User 
    UserId userId;
    Name name;
    PhoneNumber phone;
    Address address;
    RepId repId;


public User register(
  @NotNull Name name,
  @NotNull PhoneNumber phone,
  @NotNull Address address
) 
    // 找到区域内的SalesRep地推员
    SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());

    // 最后创建用户,落盘,然后返回,这部分代码实际上也能用Builder解决
    User user = new User();
    user.name = name;
    user.phone = phone;
    user.address = address;
    if (rep != null) 
        user.repId = rep.repId;
    

    return userRepo.saveUser(user);

可以看到在使用了 DP 之后,所有的数据验证逻辑和非业务流程的逻辑都消失了,剩下都是核心业务逻辑,可以一目了然。我们重新用上面的四个维度评估一下:

评估1-接口清晰度:
重构后的方法签名变成了很清晰的:

public User register(Name, PhoneNumber, Address)

而之前容易出现的bug,如果按照现在的写法

service.register(new Name("张三"), new Address("河北省沧州市吴桥县黄河路二号院"), new PhoneNumber("0571-12345678"));

让接口 API 变得很干净,易拓展。

评估2 - 数据验证和错误处理:

public User register(
  @NotNull Name name,
  @NotNull PhoneNumber phone,
  @NotNull Address address
) // no throws

如前文代码展示的,重构后的方法里,完全没有了任何数据验证的逻辑,也不会抛 ValidationException 。原因是因为 DP 的特性,只要是能够带到入参里的一定是正确的或 null(Bean Validation 或 lombok 的注解能解决 null 的问题)。所以我们把数据验证的工作量前置到了调用方,而调用方本来就是应该提供合法数据的,所以更加合适。
再展开来看,使用DP的另一个好处就是代码遵循了 DRY 原则和单一性原则,如果未来需要修改 PhoneNumber的校验逻辑,只需要在一个文件里修改即可,所有使用到了 PhoneNumber 的地方都会生效。

评估3 - 业务代码的清晰度:

SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
User user = xxx;
return userRepo.save(user);

除了在业务方法里不需要校验数据之外,原来的一段胶水代码 findAreaCode被改为了 PhoneNumber类的一个计算属性 getAreaCode,让代码清晰度大大提升。而且胶水代码通常都不可复用,但是使用了 DP 后,变成了可复用、可测试的代码。我们能看到,在刨除了数据验证代码、胶水代码之后,剩下的都是核心业务逻辑。

评估4 - 可测试性:

  • 首先 PhoneNumber 本身还是需要 M 个测试用例,但是由于我们只需要测试单一对象,每个用例的代码量会大大降低,维护成本降低。
  • 每个方法里的每个参数,现在只需要覆盖为 null 的情况就可以了,其他的 case 不可能发生(因为只要不是 null 就一定是合法的)

所以,单个方法的 TC 从原来的 N * M 变成了 N + M 。同样的,多个方法的 TC 数量变成了
N + M + P
这个数量一般来说要远低于原来的数量 N* M * P ,让测试成本极大的降低。

3.2 进阶使用

在上文介绍了 DP 的第一个原则:将隐性的概念显性化。在这里介绍 DP 的另外两个原则,用一个新的案例。

3.2.1 案例1 - 转账

假设现在要实现一个功能,让A用户可以支付 x 元给用户 B ,可能的实现如下:

public void pay(BigDecimal money, Long recipientId) 
    BankService.transfer(money, "CNY", recipientId);

如果这个是境内转账,并且境内的货币永远不变,该方法貌似没啥问题,但如果有一天货币变更了(比如欧元区曾经出现的问题),或者需要做跨境转账,该方法是明显的 bug ,因为 money对应的货币不一定是 CNY 。
在这个 case 里,当说“支付 x 元”时,除了 x 本身的数字之外,实际上是有一个隐含的概念那就是货币“元”。但是在原始的入参里,之所以只用了 BigDecimal 的原因是认为 CNY 货币是默认的,是一个隐含的条件,但是在写代码时,需要把所有隐性的条件显性化,而这些条件整体组成当前的上下文。所以 DP 的第二个原则是:
Make Implicit Context Explicit 将隐性的 上下文 显性化

所以当做这个支付功能时,实际上需要的一个入参是支付金额 + 支付货币。我们可以把这两个概念组合成为一个独立的完整概念:Money

@Value
public class Money 
    private BigDecimal amount;
    private Currency currency;
    public Money(BigDecimal amount, Currency currency) 
        this.amount = amount;
        this.currency = currency;
    

而原有的代码则变为:

public void pay(Money money1. 领域驱动设计概述 

DDD是指“领域驱动设计”(Domain-Driven Design),是一种软件设计方法论,主要关注于解决复杂业务领域的建模和实现问题。DDD的核心思想是将业务领域作为设计的核心,将业务领域的概念和规则融入到软件系统中,以满足业务需求。DDD提供了一系列的概念和技术来支持业务领域建模和实现,其中包括:

  • 领域模型:将业务领域中的概念和规则抽象出来,形成一个具有行为和状态的模型。

  • 聚合根:聚合根是领域模型中最重要的概念之一,是聚合中负责维护聚合一致性的对象。

  • 领域服务:在领域模型中无法表达的业务逻辑可以通过领域服务来实现。

  • 领域事件:当领域模型中发生重要的状态变化时,可以通过领域事件来通知其他领域对象。

  • 值对象:具有不可变性的对象,用于描述业务领域中的一些值或属性。

  • 限界上下文:将领域模型划分为不同的上下文,以便于管理和理解。

DDD强调将业务领域中的知识和概念转化为软件实现,使得软件系统更加贴近业务需求,提高软件系统的可维护性和可扩展性。

2. 领域驱动设计的原则

DDD代表领域驱动设计,是一种软件开发方法论。DDD的原则如下:

  • 将业务领域置于核心位置:将业务逻辑作为整个系统的核心,以此为中心进行设计和开发。

  • 以模型为中心:通过建立模型来对业务领域进行建模,通过模型来帮助理解业务需求。

  • 显式限界上下文:在设计和开发过程中,明确业务领域的边界,定义每个上下文的职责和限制。

  • 持久化模型:将模型持久化到数据库中,确保模型和实际数据的一致性。

  • 持续演进:业务领域和需求不断发展变化,因此设计和开发过程也应该持续演进,以适应业务需求的变化。

  • 领域专家参与:领域专家应该积极参与设计和开发过程,确保开发人员对业务领域的理解准确无误。

  • 通用语言:建立通用语言,确保团队成员和领域专家之间的交流清晰无误。

  • 设计模式:使用设计模式来解决在设计和开发过程中遇到的常见问题,提高系统的可维护性和扩展性。

这些原则可以帮助开发人员更好地理解业务需求,设计出更符合实际业务场景的系统,并提高系统的可维护性和可扩展性。

3. 领域驱动设计的最佳实践

领域驱动设计是一种非常实践性的开发方法论,下面列出一些领域驱动设计的最佳实践:

  • 理解业务领域:领域驱动设计的核心是理解业务领域,开发人员需要积极地与领域专家合作,了解业务需求,确保设计和实现符合实际需求。

  • 划分领域边界:通过明确业务领域的边界,定义每个上下文的职责和限制,帮助开发人员更好地理解业务领域,避免开发过程中出现混淆和冲突。

  • 设计模型:通过建立模型来对业务领域进行建模,帮助开发人员更好地理解业务需求,并确保开发出的系统能够满足实际需求。

  • 使用通用语言:建立通用语言,确保团队成员和领域专家之间的交流清晰无误,避免在开发过程中出现歧义或误解。

  • 使用聚合:使用聚合将相关的实体和值对象组合成一个逻辑单元,确保开发出的系统能够满足业务需求,并且易于维护和扩展。

  • 使用领域事件:在领域驱动设计中,领域事件是非常重要的概念,可以用于解耦系统中的各个部分,提高系统的可扩展性和可维护性。

  • 选择合适的持久化方案:在领域驱动设计中,选择合适的持久化方案是非常重要的,需要考虑到业务需求、系统架构、可扩展性、可维护性等因素。

  • 不断优化和改进:领域驱动设计是一种持续演进的方法论,开发人员需要不断优化和改进设计和实现过程,以适应业务需求的变化和系统架构的变化。

这些最佳实践可以帮助开发人员更好地应用领域驱动设计,设计出更符合实际需求的系统,并提高系统的可维护性和可扩展性.

4. 领域驱动设计与微服务的关系

领域驱动设计(DDD)和微服务是两种不同的概念,但它们可以相互协作以达到更好的软件设计和架构。下面是领域驱动设计和微服务之间的关系:

  • 微服务倡导领域驱动设计:微服务架构倡导将系统分解成小而自治的服务,每个服务都应该有明确的边界和职责,这就要求服务之间要使用领域语言进行交流,从而可以推动领域驱动设计的实践。

  • 领域驱动设计可以帮助划分微服务边界:在微服务架构中,微服务的划分和边界的确定是至关重要的。领域驱动设计可以帮助开发人员理解业务领域,并将相关的实体和值对象组合成一个逻辑单元,从而可以更好地划分微服务的边界。

  • 微服务可以支持DDD中的限界上下文:在领域驱动设计中,限界上下文是一个非常重要的概念,可以帮助开发人员更好地理解业务领域。微服务可以支持限界上下文的实现,每个微服务都可以专注于一个特定的业务领域,从而可以实现业务领域的自治性。

  • 领域事件可以用于微服务间的解耦:在微服务架构中,每个服务都应该是自治的,从而可以实现服务之间的解耦。领域事件是一种用于解耦的重要概念,在微服务架构中可以使用领域事件将服务之间解耦,从而实现更好的可扩展性和可维护性。

总之,领域驱动设计和微服务是两个互相协作的概念,领域驱动设计可以帮助开发人员理解业务领域,微服务可以支持限界上下文的实现,并提供更好的可扩展性和可维护性。在实践中,开发人员可以结合两个概念,以构建更好的软件系统。

5. 领域驱动设计的例子和代码

以下是一个简单的领域驱动设计的示例和代码:

假设我们正在开发一个电商网站,我们需要设计一个订单模块。订单模块需要管理订单、商品、用户和支付等领域对象。

1.领域对象
我们可以使用对象来表示领域对象,例如订单(Order)、商品(Product)、用户(User)和支付(Payment)等。这些对象具有属性和行为。

public class Order 
    private int orderId;
    private List<Product> products;
    private User user;
    private Payment payment;

    // getters and setters


public class Product 
    private int productId;
    private String name;
    private double price;

    // getters and setters


public class User 
    private int userId;
    private String username;
    private String email;

    // getters and setters


public class Payment 
    private int paymentId;
    private double amount;

    // getters and setters


  1. 限界上下文
    我们可以使用限界上下文来划分订单模块的边界。在订单模块中,我们可以定义以下限界上下文:订单、商品、用户和支付。
package com.example.order;

public class Order 
    //...


package com.example.order.product;

public class Product 
    //...


package com.example.order.user;

public class User 
    //...


package com.example.order.payment;

public class Payment 
    //...


  1. 领域服务
    在订单模块中,我们可以定义领域服务来管理订单、商品、用户和支付等对象。例如,我们可以定义一个OrderService来创建、更新和查询订单。
package com.example.order;

public interface OrderService 
    Order createOrder(List<Product> products, User user, Payment payment);
    Order getOrder(int orderId);
    void updateOrder(Order order);
    //...


4.领域事件
在订单模块中,我们可以使用领域事件来实现服务之间的解耦。例如,当订单被创建时,我们可以触发一个OrderCreatedEvent,其他服务可以订阅这个事件来执行相应的操作。

package com.example.order.event;

public class OrderCreatedEvent 
    private int orderId;
    private List<Product> products;
    private User user;
    private Payment payment;

    // getters and setters


这只是一个简单的领域驱动设计的示例和代码,实际的领域驱动设计可能更加复杂和细致。但这个示例可以帮助你了解领域驱动设计的基本概念和实践。

6. 领域驱动设计的难点

领域驱动设计(DDD)是一种复杂的设计方法,需要设计人员有丰富的经验和深入的业务领域知识。以下是领域驱动设计中的一些难点:

  • 领域模型的设计
    领域模型是领域驱动设计的核心。设计一个好的领域模型需要深入了解业务领域,同时需要考虑到多个限界上下文之间的交互。领域模型的设计需要考虑多个方面,例如实体、值对象、聚合、服务和领域事件等。

  • 领域专家的参与
    领域驱动设计需要领域专家的参与,领域专家对业务领域有深入的了解,可以帮助设计人员更好地理解业务需求和规则。但是,领域专家通常很忙,需要合理安排时间和资源。

  • 模块之间的交互
    在领域驱动设计中,模块之间的交互很重要。模块之间的交互需要考虑到多个方面,例如模块之间的依赖、模块之间的通信和模块之间的事务管理等。设计人员需要考虑如何将模块之间的交互最小化,并尽可能地减少依赖关系。

  • 技术实现的复杂性
    领域驱动设计通常需要使用一些比较复杂的技术实现,例如领域事件、聚合、事件溯源和CQRS等。这些技术实现需要设计人员有一定的技术功底和经验。

  • 团队协作和沟通
    领域驱动设计需要多个人员之间的协作和沟通。不同的人员之间可能有不同的理解和偏见,需要进行充分的沟通和协调。团队成员之间需要明确各自的职责和任务,并协作完成设计任务。

7. 领域驱动设计与CQRS关系

领域驱动设计(DDD)和命令查询责任分离(CQRS)是两个不同的概念,但它们经常一起使用来构建复杂的应用程序。

DDD关注的是领域模型的设计,它试图将业务需求转化为一组概念、规则和关系,这些概念、规则和关系组成了领域模型,通过它来实现业务逻辑。DDD提供了一些设计模式和技术,例如聚合、实体、值对象、领域服务和事件驱动等,来支持领域模型的设计。

CQRS是一种架构风格,将读操作(查询)和写操作(命令)分开处理,将它们分别传递到不同的服务或处理程序中。CQRS的核心思想是读操作和写操作的需求是不同的,它们的处理方式也应该是不同的。CQRS的实现方式通常包括一个命令模型和一个查询模型,这两个模型可能不同步,并且可以根据需求进行扩展和优化。

在DDD中,领域模型是核心,领域模型中包括了领域对象、聚合、领域服务等等,这些领域模型和CQRS一起使用可以实现更好的应用程序架构和更好的性能。

下面是一个例子,假设我们有一个在线商店,客户可以订购商品,我们使用DDD和CQRS来设计和实现这个系统。

在DDD中,我们可以定义一个Order聚合,它包含了一系列的OrderItem实体对象和Customer实体对象。当客户提交订单时,我们会创建一个新的Order对象,并将其保存到数据库中。当订单被提交后,我们会发布一个订单提交事件,然后CQRS会将事件转化为一个命令,将这个订单的详细信息发送给一个消息队列。

在查询方面,我们可以使用一个查询模型来获取订单信息。这个查询模型包含了订单的详细信息,例如订单号、订单日期、订单状态等。我们可以使用一个专门的查询服务来获取订单信息,并且可以使用缓存来提高查询性能。

通过使用DDD和CQRS,我们可以实现一个高效、可扩展和易于维护的在线商店系统。

以上是关于DDD 核心概念与 Domain primitive的主要内容,如果未能解决你的问题,请参考以下文章

Domain-Driven Design 领域驱动设计

学习:DDD领域驱动设计

浅谈我对DDD领域驱动设计的理解

Lind.DDD.Domain领域模型介绍

Lind.DDD.Domain.ISortBehavor~上移与下移

DDD(Domain Driver Designer) 领域驱动设计简介