DDD优秀实践及总结 Part Ⅰ——Domain Primitive

Part Ⅰ. Domain Primitive (DP)

原则一:将隐形概念显性化(Make Implicit Concepts Explicit)

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

  • 原始代码:
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}-?\\d{8}$";
        return phone.matches(pattern);
    }
}

问题1-接口的清晰度

接口参数名因编译时丢失,一旦入参顺序不对,难以发现错误。

User register(String, String, String);

问题2-数据校验和错误处理

当存在多个类似的接口和类似的入参,这段逻辑在每个方法里都会被重复。更严重的是未来如果我们要扩展phone的校验逻辑,必须在所有方法中修改,维护成本极高。

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

问题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);

问题4-可测试性

一个方法中有N个参数,每个参数有M个校验逻辑,P个方法都需要对该字段进行测试,整体就需要P*M*N个TC

条件/入参 name phone address
入参为null
入参为空
入参不符合要求(可能多个)
  • 解决方案:

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

}

1)这里将校验逻辑都放在了constructor里面,确保只要PhoneNumber类被创建出来后,一定是校验通过的;

2)之前的findAreaCode方法变成了PhoneNumber类里的getAreaCode,突出了areaCode是PhoneNumber的一个计算属性;

 

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

Type指我们在今后的代码里可以通过PhoneNumber去显性的标识电话号这个概念;

Class指我们可以把所有跟电话号相关的逻辑完整收集到一个文件里。

这两个概念加起来,构成了DP

 

全面使用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);
}

评估1-接口的清晰度

方法签名很清晰

public User register(Name, PhoneNumber, Address)

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

因为DP的特性,只要能够带到入参里的一定是正确的或null(只需使用Bean Validation或lombok解决null),我们把数据验证的工作量前置到调用方;

另一个好处是遵循了DRY原则和单一性原则。

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

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

使用了DP后,变成了可复用和可测试的代码。刨除了数据验证代码、胶水代码之后,剩下的都是核心业务逻辑。

评估4-可测试性

多个方法的TC数量变成M+N+P

 

原则二:将隐形的上下文显性化(Make Implicit Context Explicit)

案例:转账-假设现在要实现一个功能,让A用户可以支付 x 元给用户 B。

public void pay(BigDecimal money, Long recipientId) {
    BankService.transfer(money, "CNY", recipientId);
}
@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。

 

原则三:封装多对象行为(Encapsulate Multi-Object Behavior)

案例:前面的案例升级一下,假设用户可能要做跨境转账从 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 里,可以将转换汇率的功能,封装到一个叫做 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汇率对象,通过封装金额计算逻辑以及各种校验逻辑,让原始代码变得极其简单。

Domain primitive是Value Object的进阶版,在VO的immutable基础上增加了Validity和行为。当然同样的要求是无副作用。

  • DTO和DP的区别:
     
  DTO DP
功能

数据传输

属于技术细节

代表业务域的概念
数据的关联

只是一堆数据放在一起

不一定有关联度

数据之间的高相关性
行为 无行为 丰富的行为和业务逻辑

 

常见的DP的使用场景:有格式限制的String、有限制的Integer、可枚举的int、Double和BigDecimal、复杂的数据结构。

  • 老应用的重构

第一步-创建Domain Primitive,收集所有DP行为;

第二步-替换数据校验和无状态逻辑;

第三步-创建新接口;

第四步-修改外部调用。

 

 

 

你可能感兴趣的:(领域模型驱动)