存储库是否应该抛出域错误

Should Repositories Throw Domain Errors

我正在构建一个试图遵守干净架构的应用程序。我知道存储库旨在抽象出持久层和 return 领域语言方面的实体。但是,这是否意味着如果出现问题,它也应该检查并抛出域错误。让我们考虑一种情况,我想通过用户存储库添加用户。我可以做到以下几点:

// in user repo
const add = (user: User): void => {
  try {
    // do some database stuff
  } catch() {
    throw new EmailAlreadyInUse(user.email);
  }
}

但这种实施是否可取?我们现在依靠使用正确的唯一密钥模式正确设置数据库来执行域规则(没有两个用户可以使用相同的电子邮件注册)。在我看来,这似乎是我们可能将域规则泄漏到持久层。

从用例层抛出这个异常是否更有意义。

const AddNewUserUseCase = (userRepository, email) => {
  const user = userRepository.findByEmail(email);
  if(user) {
    throw new EmailAlreadyInUseError(email)
  }
  else {
    const user = new User(email);
    userRepository.add(user);
  }
}

这有效并消除了持久层的任何溢出。但是我必须在每个我想添加用户的地方都这样做。你会选择什么样的推荐模式?您有其他鼓励的方法吗?你会在哪里做这些检查来抛出错误。

完全依赖数据库功能来执行业务规则是一种不好的做法。

也就是说,考虑到在进行一些业务验证检查后引发域异常这一事实,您不应从代表您的数据库(存储库)的 class 内部引发域异常。

域异常,顾名思义,应该在域(或应用)层内部使用。

因此,您的重复电子邮件验证应位于用例内,然后是存储库操作(添加用户)。至于代码重复,解决方案很简单:使用包含此两阶段逻辑(验证然后操作)的方法创建域服务,然后在您喜欢的任何地方使用此服务。

干净架构的一个关键原则是形成一个稳定的领域层,同时让基础设施细节可以交换。但是,当您将业务规则放入存储库(基础架构)时,请考虑如果您决定创建替代存储库会发生什么:您必须记住将业务规则复制到新存储库中。

存储库通常在用例层声明,因为它们是用例需要什么的定义。因此,这些接口应该是面向领域的。由于它们必须在外层中实现,这意味着如果定义了外层,则外层必须引发域异常。

But is this implementation advisable? We are now relying upon the database to have been setup correctly with the correct unique key schema to enforce a domain rule (no two users can register with the same email)

从用例的角度来看,接口如何实现并不重要。您可以实现数据库、文件或内存存储库,这取决于实现如何实现存储库的接口定义。如果您实现关系数据库存储库,则可以使用 db constraints 来满足存储库的接口定义。但是您仍然必须将引发的 ConstraintViolationException 映射到域异常。

要点是存储库接口是对用例以面向域的方式想要什么而不是如何完成的描述。任何界面的本质都是描述客户想要什么而不是如何。接口是为客户而不是为实现者设计的。

域约束在接口定义,例如

public interface UserRepository {

    /**
     *
     * throws an UserAlreadyExistsException if a user with the given email already exists.
     * returns the User created with the given arguments or throws an UserAlreadyExistsException. 
     *         Never returns null.
     */
    public User createUser(String email, ....) throws UserAlreadyExistsException;

}

接口不仅仅是一个方法签名。它具有通常以非正式方式描述的前置条件和 post 条件。

备选方案

例如,在 Java 中,如果您希望实现遵循您定义的路径,您也可以使用抽象 classes。由于我不知道你使用哪种语言,我会给你这个 Java 示例。

public abstract class UserRepository {
   
     public User createUser(String email, ...) throws UserAlreadyExistsException {
        User user = findByEmail(email);

        if(user) {
            throw new UserAlreadyExistsException(email)
        } else {
            User user = new User(email);
            add(user);
        }
     }

     protected abstract findByEmail(String email);
     protected abstract add(User user);
}

但是当您使用抽象 classes 时,您已经定义了实现的一部分。实现不像接口示例中那样自由。并且您的实现必须扩展抽象 class。这可能是个问题,例如在 Java 中,因为 Java 不允许多重继承。因此,这取决于您使用的语言。

结论

我会使用第一个示例,只需定义一个抛出域异常的接口,让实现选择如何完成。

当然这意味着我通常必须使用较慢的集成测试来测试实现,而我不能使用快速的单元测试。但是用例仍然可以通过单元测试轻松测试。