Featured image of post Java工程师 DDD_02_DomainPrimitive

Java工程师 DDD_02_DomainPrimitive

🌏Java工程师 DDD学习 🎯这篇文章用于记录 DDD_02_DomainPrimitive学习

🌱前言

DDD从业务的角度出发,为纯业务的开发提供了一整套的架构思路

🎄Anemic Domain Model(贫血域模型)

概念 贫血域模型是一个设计反模式,其中领域对象(代表业务域中的事物)几乎没有或根本没有行为,它们基本上只是数据的容器。换句话说,这些对象只有getter和setter方法,而没有任何其他与业务逻辑相关的方法。

举例 假设我们有一个代表“用户”的领域对象。在贫血模型中,这个“用户”对象可能只有像getUsername()setPassword()这样的方法,但它不会有像login()changePassword()这样的行为方法。相反,这些行为方法会被放在其他服务类或管理器类中。

⚡为什么这是一个问题?

  1. 业务逻辑分散:由于领域对象没有行为,业务逻辑通常会分散在许多服务类中。这使得代码更难理解和维护(例如:用户注册和登录都要验证用户的基本信息,如何有第三个业务也要验证用户的基本信息,则又多了一个业务逻辑分散类,如果又有第4个💩…)
  2. 代码重复:当多个服务类需要执行相同的业务逻辑时,很可能会出现代码重复 (例如:用户注册和登录都要验证用户名和密码是否为空)
  3. 数据和行为分离:在现实世界中,数据和行为通常是紧密相关的。贫血模型打破了这种自然的关系,使得模型变得不直观。

为了避免这些问题,我们可以采用“富领域模型”(Rich Domain Model),在这种模型中,领域对象不仅包含数据,还包含与之相关的行为。这样,代码更加模块化、内聚和可维护。

总结:如果一个领域对象可能产生行为,则尽可能把其可能产生的行为封装到领域类本身。

🎄Domain Primitive(域原语)

概念 Domain Primitive(域原语)是领域驱动设计(Domain-Driven Design, DDD)中的一个概念,指的是在特定业务领域中,最基础、不可再分、具有明确业务含义的数据类型或对象。这些原语通常封装了业务领域中最基础的概念,并且只包含与这些概念直接相关的行为。(🍭这个表达相当精准 牛牛的)

深入浅出 用通俗易懂的话来说,Domain Primitive就像是业务领域的“积木”,它们是构成更大、更复杂业务领域对象的基石。每一个Domain Primitive都代表了一个业务领域中无法再细分的最小概念,比如“货币”、“邮箱地址”或“产品编码”等。

Domain Primitive通常具有以下几个特点:

  1. 不可变性:一旦创建,其状态就不会改变。这有助于确保数据的一致性和减少错误。
  2. 验证逻辑:在创建时,Domain Primitive会执行必要的验证逻辑,以确保它代表的数据是有效的。
  3. 无副作用:它的行为不会影响到其他对象或系统状态。
  4. 专注于业务:只包含与所代表的业务概念直接相关的行为和属性。

Domain Primitive和Anemic Domain Model之间的联系:

在Anemic Domain Model中,领域对象(如用户、订单等)通常只包含属性和简单的getter/setter方法,业务逻辑被放在了服务层或其他地方。这种模式下,领域对象变成了纯粹的数据结构,缺乏封装和业务行为的表达能力。

而Domain Primitive可以被视为一种改进策略,它通过将业务领域中最基础的概念封装成具有明确业务含义和行为的对象,来增强领域模型的表达能力。

📑实际举例1·将隐性的概念显性化

⚡原设计模式

/**
@author Liu Xianmeng
@createTime 2024/1/4 20:09
@instruction 业务举例概述:一个新应用在全国通过 地推业务员 做推广,需要做一个用户注册系统,
                        同时希望在用户注册后能够通过用户电话(先假设仅限座机)的地域(区号)
                        对业务员发奖金(事后统计)
*/

class User {
    Long userId;
    String name;
    String phone;
    String address;
    Long salesmanId; // 对应的业务员Id
}
class Salesman {
    Long salesmanId; // 业务员Id
}
interface RegistrationService {
    public User register(String name, String phone, String address) throws ValidationException;
}
class RegistrationServiceImpl implements RegistrationService {

    // 业务员数据库操作
    private SalesmanRepository salesmanRepo;
    // 用户数据库操作
    private UserRepository userRepo;

    @Override
    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的校验逻辑

        // 取电话号里的区号 然后通过区号找到区域内的salesman
        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;
            }
        }
        // 通过区号找到区域内的salesman
        Salesman salesman = salesmanRepo.findOne(areaCode);
        
        // 最后创建用户 落盘 然后返回
        User user = new User();
        user.name = name;
        user.phone = phone;
        user.address = address;
        if (salesman != null) {
            user.salesmanId = salesman.salesmanId;
        }
        return userRepo.save(user);
    }
}

⚡使用域原语指导思想重构后

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

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
    Salesman salesman = salesmanRepo.findOne(phone.getAreaCode());

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

    return userRepo.saveUser(user);
}

评估1 - 接口的清晰度

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

public User register(Name, PhoneNumber, Address)

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

service.register(new Name("name"), new Address("address"), new PhoneNumber("phone"));

让接口 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 后,变成了可复用、可测试的代码。在刨除了数据验证代码、胶水代码之后,剩下的都是核心业务逻辑。

📑实际举例2·将隐性的上下文显性化

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

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

如果这个是境内转账,并且境内的货币永远不变,该方法貌似没啥问题,但如果有一天货币变更了(比如欧元区曾经出现的问题),或者我们需要做跨境转账,该方法是明显的 bug ,因为 money 对应的货币不一定是 CNY 。

在这个 case 里,当我们说“支付 x 元”时,除了 x 本身的数字之外,**实际上是有一个隐含的概念那就是货币“元”。**但是在原始的入参里,之所以只用了 BigDecimal的原因是我们认为 CNY 货币是默认的,是一个隐含的条件,但是在我们写代码时,需要把所有隐性的条件显性化,而这些条件整体组成当前的上下文。

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

📑实际举例3·封装多对象的行为

前面的案例升级一下,假设用户可能要做跨境转账从 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 不一定和 moneyCurreny 一致,需要调用一个服务去取汇率,然后做计算。最后用计算后的结果做转账。

这个case最大的问题在于,金额的计算被包含在了支付的服务中,涉及到的对象也有2个 Currency,2 个 Money,1 个 BigDecimal ,总共 5 个对象。这种涉及到多个对象的业务逻辑,需要用 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 exchangeRate = ExchangeService.getExchangeRate(money.getCurrency(), targetCurrency);
    Money targetMoney = exchangeRate.exchange(money);
    BankService.transfer(targetMoney, recipientId);
}

⚡什么情况下应该用 Domain Primitive

常见的 DP 的使用场景包括(只要是涉及到业务逻辑就应该使用DP域原语的构造方式):

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

🎄原文链接

阿里技术专家详解 DDD 系列- Domain Primitive

Licensed under CC BY-NC-SA 4.0
最后更新于 2024年1月27日