【问题标题】:DDD / CQRS / ES - How and where to implement guardsDDD / CQRS / ES - 如何以及在何处实施警卫
【发布时间】:2019-02-01 09:47:18
【问题描述】:

早上好,

我有一个模型,其中 用户 AR 具有特定的 UserRole(管理员、经销商或客户)。对于那个 AR,我会实施一些保护措施:

  • 管理员不能拥有除自己以外的经理
  • 经销商不能有管理员以外的经理
  • 客户不能有除经销商或客户以外的经理(子帐户案例)

假设我想注册一个新的用户。流程如下:

RegisterUser 请求处理程序 -> RegisterUser 命令 -> RegisterUser 命令处理程序 -> User->register(...) 方法 ->UserWasRegistered 域事件

我应该如何以及在何处实施保护以准确验证我的用户 AR?现在,我的内容如下所示:

namespace vendor\Domain\Model;

class User
{
    public static function register(
        UserId $userId,
        User $manager,
        UserName $name,
        UserPassword $password,
        UserEmail $email,
        UserRole $role
    ): User
    {
        switch($role) {
            case UserRole::ADMINISTRATOR():
                if(!$userId->equals($manager->userId)) {
                    throw new \InvalidArgumentException('An administrator cannot have a manager other than himself');
                }
                break;
            case UserRole::RESELLER():
                if(!$manager->role->equals(UserRole::ADMINISTRATOR())) {
                    throw new \InvalidArgumentException('A reseller cannot have a manager other than an administrator');
                }
                break;
            case UserRole::CLIENT():
                // TODO: This is a bit more complicated as the outer client should have a reseller has manager
                if(!$manager->role->equals(UserRole::RESELLER()) && !$manager->role->equals(UserRole::Client())) {
                    throw new \InvalidArgumentException('A client cannot have a manager other than a reseller or client');
                }
        }

        $newUser = new static();
        $newUser->recordThat(UserWasRegistered::withData($userId, $manager, $name, $password, $email, $role, UserStatus::REGISTERED()));

        return $newUser;
    }
}

正如您在此处看到的,守卫位于 User AR 中,我认为这很糟糕。我想知道是否应该将这些警卫放在外部验证器或命令处理程序中。另一件事是我可能还应该访问读取模型以确保用户的唯一性和管理器的存在。

最后一件事是,我更愿意为 manager 属性传递一个 UserId VO 而不是 User AR,因此我认为不应该放入警卫用户 AR。

非常感谢您的建议。

【问题讨论】:

  • 你为什么使用static方法?
  • @Constantin Galbenu 因为我想表达真正的意图(无处不在的语言)。 new() 从域的角度来看没有意义,而 register() 清楚地表达了意图(注册新用户)。
  • 当然,创建对象和注册用户是两码事,但为什么要用static方法呢?
  • @Constantin Galbenu 有很多有价值的理由。见verraes.net/2014/06/named-constructors-in-php
  • 客户端必须使用 register() 方法创建用户,这清楚地说明了意图。客户端这样做真的更清楚: User::register(...) 比做 $user = new User(....); $user->register() ...但更重要的是:通过使用静态工厂,我不仅限于一个构造函数。我可以有其他工厂以不同的方式创建用户,并发布不同的域事件。你为什么要准确地问?

标签: php domain-driven-design cqrs


【解决方案1】:

正如您在此处看到的,守卫在模型中,我认为这很糟糕。我想知道是否应该将这些守卫放在外部验证器或命令处理程序中。

使用 DDD,您努力将业务逻辑保留在域层中,更具体地说,尽可能保留在模型(聚合、实体和值对象)中,以避免以 Anemic Domain Model 结束。某些类型的规则(例如访问控制、琐碎的数据类型验证等)本质上可能不被视为业务规则,因此可以委托给应用程序层,但核心域规则不应泄漏到域之外。

我更愿意为 manager 属性传递一个 UserId 值对象而不是一个用户聚合

聚合应旨在依靠其边界内的数据来执行规则,因为这是确保强一致性的唯一方法。重要的是要认识到,任何基于聚合外部数据的检查都可能是对陈旧数据进行的,因此该规则仍可能因并发而被违反。只有在违规发生后检测违规并采取相应行动,才能使规则最终保持一致。但这并不意味着检查毫无价值,因为它仍然可以防止大多数违规行为在低争用情况下发生。

在向聚合提供外部信息时,主要有两种策略:

  1. 在调用域之前查找数据(例如在应用程序服务中)

    • 示例(伪代码):

      Application {
          register(userId, managerId, ...) {
              managerUser = userRepository.userOfId(userId);
              //Manager is a value object
              manager = new Manager(managerUser.id(), managerUser.role());
              registeredUser = User.register(userId, manager, ...);
              ...
          }
      }
      
    • 何时使用?这是最标准的方法,也是“最纯粹的”(聚合从不执行间接 IO)。我总是首先考虑这个策略。

    • 要注意什么? 就像在您自己的代码示例中一样,将 AR 传递给另一个方法可能很诱人,但我会尽量避免它以防止传递了 AR 实例,并且还避免创建对超出需要的合约的依赖项。

  2. 将域服务传递给域,它可以使用它自己查找数据。

    • 示例(伪代码):

      interface RoleLookupService {
          bool userInRole(userId, role);
      }
      
      Application { 
          register(userId, managerId, ...) {
              var registeredUser = User.register(userId, managerId, roleLookupService, ...);
              ...
          }
      }
      
    • 何时使用?当查找逻辑本身足够复杂以关心将其封装在域中而不是将其泄漏到应用程序层时,我会考虑这种方法。但是,如果您想保持聚合“纯度”,您还可以在应用层所依赖的工厂(域服务)中提取整个创建过程。

    • 需要注意什么?您应该始终牢记Interface Segregation Principle,并避免传递诸如IUserRepository 之类的大合同,因为唯一需要注意的是用户有一个角色。此外,这种方法不被认为是“纯粹的”,因为聚合可能正在执行间接 IO。与单元测试的数据依赖项相比,服务依赖项可能还需要更多的工作来模拟。

重构原始示例

  • 避免传递另一个 AR 实例
  • 将监督政策政策明确建模为与特定角色相关联的一等公民。请注意,您可以使用规则与角色相关联的任何建模变体。我不一定对示例中的语言感到满意,但您会明白的。

    interface SupervisionPolicy {
        bool isSatisfiedBy(Manager manager);
    }
    
    enum Role {
        private SupervisionPolicy supervisionPolicy;
    
        public SupervisionPolicy supervisionPolicy() { return supervisionPolicy; }
    
        ...
    }
    
    
    class User {
        public User(UserId userId, Manager manager, Role role, ...) {
            //Could also have role.supervisionPolicy().assertSatisfiedBy(manager, 'message') which throws if not satsified
            if (!role.supervisionPolicy().isSatisfiedBy(manager)) {
                throw …;
            }
        }
    }
    

【讨论】:

    【解决方案2】:

    通常 - 领域驱动设计需要丰富的领域模型,这通常意味着业务逻辑位于代表领域部分的方法中。

    这通常意味着命令处理程序将负责管道(从数据库加载数据,将更改存储在数据库中),并将计算用户请求后果的工作委托给域模型。

    所以“守卫”通常会在域模型中实现。

    最后一件事是,我更愿意为 manager 属性传递一个用户 ID 而不是一个用户,因此我认为不应该将守卫放在用户模型中。

    这很好 - 当域模型需要非本地信息时,您通常要么查找该信息并将其传递进来,要么传递查找信息的能力。

    因此,在这种情况下,您可能会传入一个“域服务”,它知道如何在给定 UserId 的情况下查找 UserRole。

    您是在告诉我将域服务传递给聚合是完全有效的吗?在实例化级别还是仅针对处理的方法?

    我的强烈偏好是服务作为参数传递给需要它们的方法,并且不是实例化的一部分。所以领域模型中的实体持有数据,并按需提供协作者。

    “域服务”是 Evans 在蓝皮书第 5 章中描述的域模型的第三个元素。在许多情况下,领域服务描述了一个接口(用模型的语言编写),但接口的实现是在应用程序或基础设施“层”中。

    所以我永远不会将 repository 传递给域模型,但我会传递一个将实际工作委托给存储库的域服务。

    【讨论】:

    • 首先,感谢您的回答,非常感谢。您是在告诉我将域服务传递给聚合是完全有效的吗?在实例化级别还是仅针对处理的方法?基本上,从域服务,我也可以查询存储库,对吧?事实上,我的想法是实现一个域服务,然后触发 UserWasRegistered 域事件,但我肯定错了这个想法......所以?
    • 如果我理解你的正确,这篇文章可以恢复你的所有建议:ocramius.github.io/blog/… 现在我在你的答案中没有得到的重点是“我永远不会将存储库传递给域模型,但我会传递一个将实际工作委托给存储库的域服务”......基本上,如果没有将存储库传递给它(依赖注入),域服务如何将工作委托给存储库?再次感谢您。
    • @Nuxwin 例如,RoleLookupService.isUserInRole(role) 而不是 UserRepository.findUserById(userId).role.equals(role)
    • @plalx 所以,RoleLookupService 依赖于 UserRepository,对吧?感谢您的贡献。非常感谢。
    猜你喜欢
    • 2021-11-02
    • 2018-09-05
    • 1970-01-01
    • 2017-03-08
    • 2010-12-03
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2017-05-12
    相关资源
    最近更新 更多