第二章:Domain Primitive

上一章:《第一章:初识DDD》

1.前言

在第一章中我们初步了解DDD的概念,以及DDD中经常提到的贫血和充血模型,由于 DDD 不是一套框架,而是一种架构思想,所以在代码层面缺乏了足够的约束,导致 DDD 在实际应用中上手门槛很高。

让我关注DDD的原因除了公司内整个架构采用DDD的设计之外,它有一个思想Anti-Corruption Layer(防腐层)的概念,特别是其在解决外部依赖频繁变更的情况下,如何将核心业务逻辑和外部依赖隔离的机制

随着分布式架构设计的推广,SOA(面向服务的架构)也开始大行其道,他是一种设计方法,包含多个服务,服务之间通过相互依赖最终提供一系列的功能, 一个服务通常以独立的形式存在与操作系统进程中,各个服务之间通过网络调用,而如何将整个应用合理的拆分为多个微服务成为了各大论坛的热门话题,而 DDD 里面的 Bounded Context(限界上下文)的思想为微服务拆分提供了一套合理的框架。

而在今天,在一个所有的东西都能被称之为“服务”的时代(XAAS), DDD 的思想让我们能冷静下来,去思考到底哪些东西可以被服务化拆分,哪些逻辑需要聚合,才能带来最小的维护成本,而不是简单的去追求开发效率。

之所以在介绍完DDD的概念和思想之后就介绍Domain Primitive是因为学习架构和学习语言一样,在学任何语言时首先需要了解的是基础数据类型一样,在全面了解 DDD 之前,首先给大家介绍一个最基础的概念: Domain Primitive(DP)。

Primitive 的定义是:

不从任何其他事物发展而来
初级的形成或生长的早期阶段

就好像 Integer、String 是所有编程语言的 Primitive 一样,在 DDD 里, DP 可以说是一切模型、方法、架构的基础,而就像 Integer、String 一样, DP 又是无所不在的。

但我们先不去讲概念,而是从案例入手,看看为什么 DP 是一个强大的概念。

2.案例分析

我们实现一个最简单的用户注册功能,需要输入用户的名字,电话(带区号的座机),地址。并且后台需要根据电话信息来统计哪个区域注册用户最多。那么就可以看出来电话需要自己的校验逻辑,来判断号码是否合法,以及拆分出区号

传统的实现方式:

  • 首先我们需要一个对象来承载用户信息
public class User {
    Long userId;
    String name;
    String phone;
    String address;
    Long repId;
}
  • 然后还需要一个校验功能来校验用户号码是否正确
    private boolean isValidPhoneNumber(String phone) {
        String pattern = "^0[1-9]{2,3}-?\\d{8}$";
        return phone.matches(pattern);
    }
  • 最后就是一个注册方法,将用户信息入库
public class RegistrationServiceImpl implements RegistrationService {

    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;
        return userRepo.save(user);
    }

我们日常绝大部分代码和模型其实都跟这个是类似的,但是我们在分析接口时可以从以下四个维度去分析一下:

  • 接口的清晰度(可阅读性)
  • 数据验证和错误处理
  • 业务逻辑代码的清晰度
  • 可测试性

2.1 案例分析

2.1.1 接口的清晰度

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

User register(String, String, String);
service.register("ninesun", "浙江省杭州市", "0571-12345678");

当然,在真实代码中运行时会报错,但这种bug是在运行时被发现的,而不是在编译时。

普通的 Code Review 也很难发现这种问题,很有可能是代码上线后才会被暴露出来。这里的思考是,有没有办法在编码时就避免这种可能会出现的问题?

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

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

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

这里的思考是,有没有办法让方法入参一目了然,避免入参错误导致的 bug?

2.1.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 原则被违背时经常会发生的问题。

上面的代码还只是普通的参数校验,如果要求把入参错误的原因返回,那么这段代码就变得更加复杂:

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}-?\\d{8}$") 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 单一性原则,导致代码混乱和不可维护
    • 业务异常和校验异常还是会混杂

所以,有没有一种方法,能够一劳永逸的解决所有校验的问题以及降低后续的维护成本和异常处理成本呢?

2.1.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);

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

这种代码通常被称作“胶水代码”,其本质是由于外部依赖的服务的入参并不符合我们原始的入参导致的。比如,如果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。

但是这里要思考的是,静态工具类是否是最好的实现方式呢?当你的项目里充斥着大量的静态工具类,业务代码散在多个文件当中时,你是否还能找到核心的业务逻辑呢?

2.1.4 可测试性

为了保证代码质量,每个方法里的每个入参的每个可能出现的条件都要有相应的覆盖(假设我们先不去测试内部业务逻辑),所以在我们这个方法里需要以下的覆盖场景:

条件\入参namephoneaddress
入参为Nul
入参为空
入参不符合要求

假如一个方法有 N 个参数,每个参数有 M 个校验逻辑,至少要有 N * M 个用例去测试

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

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

在这个情况下,降低测试成本 == 提升代码质量,如何能够降低测试的成本呢?

2.2 解决方案

我们重新研究一下原始的业务场景,并且标注其中可能重要的概念:

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

在分析案例以后,发现其中地推业务员、用户本身自带 ID 属性,属于 Entity(实体)

而注册系统属于 Application Service(应用服务),这几个概念已经有存在。

但是发现电话号这个概念却完全被隐藏到了代码之中,我们在深入思考一下,取电话号的区号的逻辑是否属于

  • 用户(用户的区号)?
  • 是否属于注册服务(注册的区号)?

如果都不是,那就说明这个逻辑应该属于一个独立的概念。所以这里引入我们第一个原则:将隐形的概念显性化
在这里插入图片描述

在这里,我们可以看到,原来电话号仅仅是用户的一个参数,属于隐形概念,但实际上电话号的区号才是真正的业务逻辑,而我们需要将电话号的概念显性化,通过写一个 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}-?\\d{8}$";
        return number.matches(pattern);
    }

}

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

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

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

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

这两个概念加起来,构造成了本文标题的 Domain Primitive(DP)

上面提到了一个新的概念,value object(VO),它是我们DDD领域中的视图对象,主要对应界面显示的数据对象。对于一个WEB页面,或者SWT、SWING的一个界面,用一个VO对象对应整个界面的值。

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

2.2.1 接口的清晰度

重构后的方法签名变成了很清晰的:

public User register(Name, PhoneNumber, Address)

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

service.register(new Name("ninesun"), new Address("浙江省杭州市"), new PhoneNumber("0571-12345678"));

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

2.2.2 数据验证和错误处理

我们摆脱了通过方法去显式的处理入参,而是把数据验证的工作量前置到了调用方,而调用方本来就是应该提供合法数据的,所以更加合适。

再展开来看,使用 DP 的另一个好处就是代码遵循了 DRY 原则和单一性原则,如果未来需要修改 PhoneNumber的校验逻辑,只需要在一个文件里修改即可,所有使用到了 PhoneNumber 的地方都会生效。

上面一直提到DRY原则,可能很多人知道这个概念,本文不过多赘述,只用作科普:

DRY原则

设计模式中的DRY原则(Don’t Repeat Yourself)是一种软件开发的核心原则,它的核心理念是要求开发者避免不必要的代码重复,提倡将所有具有相同或相似功能的代码块抽象出来,以减少冗余并提升代码的可维护性和可扩展性。

在实践中,DRY原则体现在以下几个方面:

  1. 实现逻辑重复:当多个地方出现相同的算法、处理流程或业务逻辑时,应当考虑将这些逻辑提取成独立的模块、函数或类,使得修改一处逻辑就能影响到所有相关位置。

  2. 功能语义重复:即使代码实现看上去不同,但如果它们完成的功能本质上是相同的,也应该考虑抽象出共享的组件或服务。不过需要注意的是,如果功能语义上有明确的区别,则不应为了追求形式上的统一而违背功能的语义,即所谓的“语义不重复”。

  3. 代码执行重复:如果有大量重复的代码片段在不同上下文中被执行,应通过函数封装、类的继承与多态、模板方法等机制来减少这种重复。

遵循DRY原则有助于提高软件质量,因为它能够减少错误的可能性(一处修改,处处生效),同时也能简化维护工作,增强系统的灵活性和一致性。设计模式如工厂模式、策略模式、模板方法模式等都是在实践中体现DRY原则的有效手段。

2.2.3 业务代码的清晰度

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

除了在业务方法里不需要校验数据之外,原来的一段胶水代码 findAreaCode 被改为了 PhoneNumber 类的一个计算属性 getAreaCode,让代码清晰度大大提升。

而且胶水代码通常都不可复用,但是使用了 DP 后,变成了可复用、可测试的代码。

我们能看到,在刨除了数据验证代码、胶水代码之后,剩下的都是核心业务逻辑。( Entity 相关的重构在后面文章会谈到,这次先忽略)

2.2.4 可测试性

条件\入参namephoneaddress
入参为Nul
入参为空--
入参不符合要求--

当我们将 PhoneNumber 抽取出来之后,在来看测试的 TC (Test Case:测试用例):

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

所以,单个方法的 TC 从原来的 N * M 变成了今天的 N + M 。

同样的,多个方法的 TC 数量变成了:N + M + P

这个数量一般来说要远低于原来的数量 N* M * P ,让测试成本极大的降低。

2.3 案例总结

维度传统的代码使用DP之后的代码
API接口清晰度模糊不清接口简洁,清晰可读
数据校验、错误处理校验逻辑分布在多个地方,存在大量重复代码校验逻辑内聚,在接口边界外部完成
业务代码清晰度校验代码、胶水代码、业务逻辑混杂无胶水代码,业务逻辑清晰,可读性极强
测试复杂度N*P*MN+P+M
其它-将隐含的概念显性化
整体安全性大大提升
Immutability不可变
线程安全

3.Domain Primitive介绍

通过上面的案例分析,我们对DP有了个初步的认知和了解,下面我们先了解其具体的定义、原则,并将其应用到我们的项目中

3.1 Domain Primitive 定义

Domain Primitive (简称 DP)是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object 。

  • DP 是一个传统意义上的 Value Object,拥有 Immutable 的特性
  • DP 是一个完整的概念整体,拥有精准定义
  • DP 使用业务域中的原生语言
  • DP 可以是业务域的最小组成部分、也可以构建复杂组合

注:Domain Primitive的概念和命名来自于Dan Bergh Johnsson & Daniel Deogun的书 《Secure by Design》。

3.2 Domain Primitive 的原则

  • 让隐性的概念显性化
  • 让隐性的上下文显性化
  • 封装多对象行为

3.3 DP和 VO 的区别

在 DDD 中, Value Object 这个概念其实已经存在:

  • 在 Evans 的 DDD 蓝皮书中,Value Object 更多的是一个非 Entity 的值对象
  • 在 Vernon 的IDDD红皮书中,作者更多的关注了Value Object 的 Immutability、Equals方法、Factory方法等
  • Domain Primitive 是 Value Object 的进阶版,在原始 VO 的基础上要求每个 DP 拥有概念的整体,而不仅仅是值对象。在 VO 的 Immutable 基础上增加了 Validity 和行为。当然同样的要求无副作用(side-effect free)。

3.3 DP和 DTO 的区别

DTO:Data Transfer Object,数据传输对象,主要用于远程调用等需要大量传输对象的地方。

比如我们一张表有 100 个字段,那么对应的 PO 就有 100 个属性。但是我们界面上只要显示 10 个字段,客户端用 WEB service 来获取数据,没有必要把整个 PO 对象传递到客户端,这时我们就可以用只有这 10 个属性的 DTO 来传递结果到客户端,这样也不会暴露服务端表结构。到达客户端以后,如果用这个对象来对应界面显示,那此时它的身份就转为 VO。

在这里,我们泛指用于展示层与服务层之间的数据传输对象

DTODP
功能用于数据传输,属于技术细节代表业务领域中的概念
数据的关联只是数据对方在一起,不一定具有关联性数据之间的高相关性
行为无行为丰富的行为和业务逻辑

3.4 Domain Primitive 的使用场景

常见的 DP 的使用场景包括:

  • 有格式限制的 String:比如Name,PhoneNumber,OrderNumber,ZipCode,Address等
  • 有限制的Integer:比如OrderId(>0),Percentage(0-100%),Quantity(>=0)等
  • 可枚举的 int :比如 Status(一般不用Enum因为反序列化问题)
  • Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等
  • 复杂的数据结构:比如 Map<String, List> 等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为。

4.进阶使用

在第二章中,我们用到了DP的第一个原则:将隐性的概念显性化

本章中用一个新的案例来介绍 DP 的另外两个原则。

4.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 的第二个原则是:让隐性的上下文显性化

所以当我们做这个支付功能时,实际上需要的一个入参是支付金额 + 支付货币。我们可以把这两个概念组合成为一个独立的完整概念: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 money, Long recipientId) {
    BankService.transfer(money, recipientId);
}

通过将默认货币这个隐性的上下文概念显性化,并且和金额合并为 Money,我们可以避免很多当前看不出来,但未来可能会暴雷的 bug。

4.2 案例 2 - 跨境转账

简单的将4.1中的案例升级一下,假设用户可能要做跨境转账从 CNY 到 USD ,并且货币汇率随时在波动:

public void pay(Money money, Currency targetCurrency, Long recipientId) {
    if (money.getCurrency().equals(targetCurrency)) {
        BankService.transfer(money, recipientId);
    } else {
        BigDecimal rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
        BigDecimal targetAmount = money.getAmount().multiply(new BigDecimal(rate));
        Money targetMoney = new Money(targetAmount, targetCurrency);
        BankService.transfer(targetMoney, recipientId);
    }
}

在这个 case 里,由于 targetCurrency 不一定和 money 的 Curreny 一致,需要调用一个服务去取汇率,然后做计算。最后用计算后的结果做转账。

这个 case 最大的问题在于,金额的计算被包含在了支付的服务中,涉及到的对象也有 2 个 Currency,2 个 Money,1 个 BigDecimal,总共 5 个对象。这

种涉及到多个对象的业务逻辑,需要用 DP 包装掉,所以这里引出 DP 的第三个原则:封装多对象行为

在这个 case 里,可以将转换汇率的功能,封装到一个叫做 ExchangeRate 的 DP 里:

@Value
public class ExchangeRate {
    private BigDecimal rate;
    private Currency from;
    private Currency to;

    public ExchangeRate(BigDecimal rate, Currency from, Currency to) {
        this.rate = rate;
        this.from = from;
        this.to = to;
    }

    public Money exchange(Money fromMoney) {
        notNull(fromMoney);
        isTrue(this.from.equals(fromMoney.getCurrency()));
        BigDecimal targetAmount = fromMoney.getAmount().multiply(rate);
        return new Money(targetAmount, to);
    }
}

ExchangeRate 汇率对象,通过封装金额计算逻辑以及各种校验逻辑,让原始代码变得极其简单:

public void pay(Money money, Currency targetCurrency, Long recipientId) {
    ExchangeRate rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
    Money targetMoney = rate.exchange(money);
    BankService.transfer(targetMoney, recipientId);
}

5.总结

可能有的同学看到此处,还无法对DP的概念有个完整、清晰的认知,我们将第二章中的场景对应的流程串行起来,可能就很清晰了

重构DP的第一步就是收集所有 DP 行为

5.1 创建 Domain Primitive,收集所有 DP 行为

在前文中,我们发现取电话号的区号这个是一个可以独立出来的、可以放入 PhoneNumber 这个 Class 的逻辑。

类似的,在真实的项目中,以前散落在各个服务或工具类里面的代码,可以都抽出来放在 DP 里,成为 DP 自己的行为或属性。

这里面的原则是:所有抽离出来的方法要做到无状态

比如原来是 static 的方法,如果原来的方法有状态变更,需要将改变状态的部分和不改状态的部分分离,然后将无状态的部分融入 DP 。

因为 DP 本身不能带状态,所以一切需要改变状态的代码都不属于 DP 的范畴。

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}-?\\d{8}$";
        return number.matches(pattern);
    }

}

5.2 替换数据校验和无状态逻辑

为了保障现有方法的兼容性,在第二步不会去修改接口的签名,而是通过代码替换原有的校验逻辑和根 DP 相关的业务逻辑。

传统的开发方式:

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");
    }
    
    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);
    // 其他代码...
}

通过 DP 替换代码后:

public User register(String name, String phone, String address)
        throws ValidationException {
    
    Name _name = new Name(name);
    PhoneNumber _phone = new PhoneNumber(phone);
    Address _address = new Address(address);
    
    SalesRep rep = salesRepRepo.findRep(_phone.getAreaCode());
    // 其他代码...
}

通过 new PhoneNumber(phone) 这种代码,替代了原有的校验代码。

通过 _phone.getAreaCode() 替换了原有的无状态的业务逻辑。

5.3 创建新接口,将 DP 的代码提升到接口参数层

public User register(Name name, PhoneNumber phone, Address address) {
    SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
}

5.4 修改外部调用

service.register("ninesun", "0571-12345678", "浙江省杭州市");
改为:
service.register(new Name("ninesun"), new PhoneNumber("0571-12345678"), new Address("浙江省杭州市"));

通过以上 4 步,就能让你的代码变得更加简洁、优雅、健壮、安全。


参考文献

《阿里技术专家详解 DDD 系列- Domain Primitive》
《Domain Primitive 使用推荐》
《DDD深入浅出》
《小米内部 DDD 脚手架》
极客时间欧创新的《 DDD 实战课》
小米科技:《一篇带你入门DDD实战》


下一章:《第三章:DDD四种模型详解》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ZNineSun

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值