使用Spring Data JPA实现DDD聚合的动态投影

投影是从存储库加载的DDD聚合 的子集,用于只读目的。

返回投影的方法通常在存储库级别上定义,使存储库接口了解应用程序中使用的所有可能类型的投影。

package com.app.account.domain;

public interface AccountRepository extends Repository<Account, String> {
    AccountBasic findAccountBasicById(String id);
    AccountComplete findAccountCompleteById(String id);
}

public record AccountBasic(String id, 
                           String iban,
                           String bic) {}

public record AccountComplete(String id,
                       String iban,
                       String bic,
                       State state,
                       Type type,
                       LocalDateTime createdAt) {}


这种方法有一些缺点:

  • 如果有一个投影几乎适合我们的用例,那么很容易修改它并添加所需的字段 - 同时有点违背了投影的主要目的 - 只获取需要的内容。
  • 命名投影成为一个挑战——如何命名它们?AccountBasic?AccountLight?AccountData?所有这些名字都毫无意义。
  • 存储库变得很难使用,因为它包含一长串方法,并且要找出应该使用哪个方法,您需要访问每个投影。
  • 由于投影在用例之间共享,因此它们成为耦合点,使未来的重构更加困难。
  • 投影必须与存储库具有相同的可见性级别,例如,如果存储库是公共的,则投影也必须是公共的。

我们可以使用动态投影,而不是让存储库了解所有用例使用的所有类型投影。

动态投影
动态投影是 Spring Data JPA 鲜为人知和较少使用的功能之一。
动态投影允许您在存储库级别定义采用任何类型投影的通用方法,并且不知道投影的内容是什么样?

package com.app.account.domain;

public interface AccountRepository extends Repository<Account, String> {
    <T> T findById(String id, Class<T> projection);
}

现在,在任何包中我们都可以定义一个投影接口或一条记录,只要它与实体的结构匹配Account,就可以与这个动态存储库方法一起使用:

package com.app.account;

record AccountBasic(String id, 
                    String iban, 
                    String bic) {}

// ...
AccountBasic account = accountRepository.findById(id, AccountBasic.class);

在本示例中,AccountBasic 是 com.app.account 中的一个包私有类。只有该软件包中的类才能访问它,因此不存在在应用程序其他部分滥用该投影的风险。

本地记录投影
通过使用本地记录,我们可以将隐藏投影的可视性提升到一个新的水平。

在方法内部定义的记录被称为本地记录,它是使用案例的完美解决方案,在使用案例中,投影仅在该方法内部使用,不会返回:

class BankTransferService {
    private final AccountRepository accountRepository;
    
    // ...
    
    void transferMoney(String sourceAccountId, String targetAccountId) {
        record AccountBasic(String id,
                            String iban,
                            String bic) {}
        
        var source = accountRepository.findById(sourceAccountId, AccountBasic.class);
        var target = accountRepository.findById(targetAccountId, AccountBasic.class);
       
// ...
    }
}


动态投影的缺点
这种方法有一些缺点值得考虑:

  • 存储库不再是查看从应用程序代码执行的所有可能查询的单一位置
  • 经过测试的存储库并不意味着测试了与数据库的所有交互 - 任何使用动态投影的地方都必须有相应的使用真实数据库的集成测试
  • 返回动态投影的存储库方法部分where仅从方法名称解析 - 无法编写自定义JPQL或SQL查询