在命令和事件中使用值对象?

Use value object in command and event?

我们可以在命令中使用值对象吗?

假设我有一个商店(聚合),其中有一个值对象地址。 在值对象构造函数 Address 中,我放置了地址的一些验证逻辑。 因此,如果我在命令 (CreateShopCmd) 中使用该地址对象,那么它会在创建命令时得到验证,但我想要的或阅读的验证应该出现在命令处理程序中。

但问题是,我必须再次将验证放入命令处理程序(因为地址构造函数中已经存在验证),如果我不将其放入命令处理程序,那么验证将在我在事件处理程序中创建 Address 对象并分配给 Shop 聚合(这是不正确的)

所以,请指导我。

下面是代码示例

   @Aggregate
   @AggregateRoot
   public class Shop {

   @AggregateIdentifier
   private ShopId shopId;
   private String shopName;
   private Address address;

   @CommandHandler
   public Shop(CreateShopCmd cmd){

     //Validation Logic here , if not using the Address in 
     // in cmd

         //Fire an event after validation
         ShopRegistredEvt shopRegistredEvt = new ShopRegistredEvt();
         AggregateLifecycle.apply(shopRegistredEvt);
     }

     @EventSourcingHandler
     public void on(ShopRegistredEvt evt) {

     this.shopName = evt.getShopName();

     //Validation happend here if not put in cmd at the time of making 
     //Address object - this is wrong
     this.address = new Address(evt.getCity(),evt.getCountry(),evt.getZipCode())

     }


   }

  public class CreateShopCmd{

    private String shopId;
    private String shopName;
    private String city;
    private String zipCode;
    private String country;

   }

 public ShopCreatedEvent{

    private String shopId;
    private String shopName;
    private String city;
    private String zipCode;
    private String country;

}

在命令或事件中使用值对象在概念上没有错。但是,您应该谨慎使用它们。

消息的结构可能会随着时间而改变。如果您在消息中过多地使用了值对象,则其中一个值对象的更改如何改变不同消息的结构可能会变得不太清楚。

对于代表 "common" 概念的值对象,例如地址,这不是什么大问题。但是一旦值对象变得更多 domain-specific,这可能会成为一个问题。

我不建议在命令中使用值对象。因为您的命令是应用程序层的一部分,但值对象保留在域层中。不过,您可以在 DomainEvens 中使用您的 ValueObjects。因为如果域模型发生变化,那么修改域事件就不会那么痛苦,因为修改是在同一个限界上下文中完成的。不过,您永远不应在集成事件中使用 ValueObjects。

这是一个很好的问题,我一直在认真考虑是否在命令中嵌入值对象。我得出的结论是你绝对不应该在命令中使用值对象:

命令是应用层的一部分,它们应该尽可能简单地工作,避免任何类型化的对象,并且最好使用文字(想想序列化)。当外部系统想要插入您的六边形(应用程序层)并向您的应用程序发送命令时会发生什么,他们是否需要您的命令库才能使用定义的对象和结构?一定不行 !你不想要那个,所以保持命令简单。

还有一个原因,正如DmitriBodiu所说,VO包含业务逻辑和验证,它们属于领域层,永远不要将它们放在命令中。应用程序服务将进行翻译,并负责向客户端的任何不符合要求的命令抛出验证错误。

您的设计没有任何问题,实际上是 Vaughn Vernon(《实现领域驱动设计》- IDDD 一书的作者)在他的存储库中所做的,您可能需要在此 link 检查应用层:

https://github.com/VaughnVernon/IDDD_Samples/blob/master/iddd_identityaccess/src/main/java/com/saasovation/identityaccess/application/IdentityApplicationService.java

注意他如何将每个对象从平面命令重构为属于域层的值对象:

@Transactional
public void changeUserContactInformation(ChangeContactInfoCommand aCommand) {
    User user = this.existingUser(aCommand.getTenantId(), aCommand.getUsername());

    this.internalChangeUserContactInformation(
            user,
            new ContactInformation(
                    new EmailAddress(aCommand.getEmailAddress()),
                    new PostalAddress(
                            aCommand.getAddressStreetAddress(),
                            aCommand.getAddressCity(),
                            aCommand.getAddressStateProvince(),
                            aCommand.getAddressPostalCode(),
                            aCommand.getAddressCountryCode()),
                    new Telephone(aCommand.getPrimaryTelephone()),
                    new Telephone(aCommand.getSecondaryTelephone())));
}

命令不能包含业务逻辑,因此不能携带值对象。

简答:有没有想过IntegerStringBoolean等等?这些也是值对象。唯一的区别是,您没有自己创建它们。现在尝试构建一个没有任何值对象的命令 ;-)

长答案: 总的来说,我没有看到命令中的值对象有任何问题。只要您遵循一些简单的准则:

应用程序中最重要的代码是域模型。域模型定义了它期望用于命令处理的数据结构。这意味着:更改您的命令模型的唯一原因是您的域模型是否需要此更改。这同样适用于您的值对象:只有在您的域模型需要此更改时,值对象才会更改。没有例外!

命令通常会因业务限制或无效数据(或乐观锁定等原因)而失败。

如上所述:整数和字符串也是值对象。如果您只在 Command 中使用基本类型,那么如果您尝试 new SetAgeCommand(aggId, "foo"),它已经抛出异常,因为 String cannot be assigned to int。如果您没有为您的 UpdatePersonCommand 提供聚合 ID,这同样适用。这些是没有 业务约束,而是非常基本的数据和类型验证。如果您传递格式错误的数据,您的命令将永远不会被创建。

现在假设您有一个 PersonAge 值对象。我不在乎你在哪里构造这个对象,因为在任何情况下,如果你试图用负数构造它,它都必须抛出一个异常:-5 cannot be assigned to PersonAge - 看起来很熟悉?只要您可以确保 您的 代码创建了那些值对象实例,您就可以确定它们是有效的。

业务规则应由域模型中的命令处理程序检查。一般来说,业务约束特定于您的域,并且通常它们依赖于聚合中的数据。以 SendMoneyCommand 为例。您的 Money 值对象可以验证它是否是有效货币,但无法验证用户的银行帐户是否有足够的钱来执行交易。这是业务验证,它是域模型的一部分。

还有关于事件的一句话:我建议只在事件中使用非常基本的 值对象。例如:StringIntegerDate等。基本上每一种值对象永远不会改变。其背后的原因:业务需求可以改变。例如:也许您的域模型需要更改 Address 值对象,现在 需要 提供地理坐标。那么这将隐式地改变你的NewAddressAddedEvent。但是你已经持久化的事件没有这个要求,虽然你无法从你过去的事件数据构造 Address 值对象,因为如果没有新的 Address 值对象将抛出异常提供地理坐标。

这个问题有(至少)两个解决方案:

  1. 版本化事件:修改您的 Address 值对象后,您现在有一个 NewAddressAddedEvent_Version2,它使用新的 Address 值对象,并且您有旧的 NewAddressAddedEvent必须使用旧 Address 值对象的备份副本。
  2. 通过向使用 Address 值对象的每个事件添加地理坐标,编写 "repairs" 您的事件数据库的脚本。所以你可以扔掉旧的 NewAddressAddedEvent.

只要值对象在概念上是您的消息协定的一部分,而不是在实体中使用,就可以了。

如果它们是您实体的一部分,请不要将它们公开为消息的 public 属性,否则您会陷入困境。