【问题标题】:Where to put business logic with side effects in DDD?在 DDD 中将具有副作用的业务逻辑放在哪里?
【发布时间】:2021-10-16 20:26:54
【问题描述】:

想象一下,我们的任务是实现一个 API 来检查是否可以将折扣计数应用于订单。 Order 域对象包含购物篮中的物品以及客户 ID:

class Order(
    val items: List<Item>,
    val customerId: CustomerId
)

我们还有一个域对象DiscountCode 表示要使用的折扣计数。

有几个验证规则可以检查给定的折扣计数是否可以应用于给定的订单:

  1. 折扣计数是否过期?
  2. 订单中有不能打折的商品吗?
  3. 此折扣代码是否已被其他人使用?
  4. (客户可以使用这个折扣码吗?)

对于规则 1-3,我们可以说它们显然是业务逻辑,根据 DDD 属于 DiscountCode 聚合:

class DiscountCode(
    val id: DiscountCodeId,
    val hasAlreadyBeenUsed: Boolean,
    val startTime: LocalDateTime,
    val endTime: LocalDateTime
) {
    fun isApplicableToOrder(order: Order) {
        return 
            startTime.isBefore(now) && endTime.isAfter(now) // rule 1
            && order.items.none(_.canNotBeDiscounted) // rule 2
            && !hasAlreadyBeenUsed // rule 3
    }
}

我们可以轻松地从数据库中加载这个DiscountCode,然后调用上面的函数来检查它是否可以与给定的订单一起使用而不会产生任何副作用。

问题是如何处理规则 4:规则 4 不能仅使用 DiscountCode 类检查,除非我们将所有允许客户的列表嵌入到 DiscountCode 类中,如果有数千个,这是不可行的顾客。同样,我们不能将允许的折扣代码列表嵌入到 Customer 类中,因为可能有很多。在数据库中,我们可以添加一个包含有效客户 + 折扣代码元组的新表:

class DiscountCodeCustomerBinding(
    val customerId: CustomerId,
    val discountCodeId: DiscountCodeId
)

因此,为了检查规则 4,我们需要对该数据库表进行另一次查询。

遵循 DDD,规则 1-3 和规则 4 的业务逻辑应该放在哪里?我们不能对DiscountCode 类中的规则 4 进行数据库查询,因为它是一个副作用。我们可以将规则 1-4 移动到允许进行数据库查询的域服务中,但现在我们创建了一个贫血的域模型。将规则 1-3 放入 DiscountCode 类,将规则 4 放入单独的域服务中,将逻辑拆分为几个非常容易出错的地方。

【问题讨论】:

    标签: domain-driven-design


    【解决方案1】:

    这是域模型三难困境的另一个很好的例子:

    1.纯度:没有进程外依赖

    应用程序服务将加载域所需的状态 做出决定并通过方法参数提供这种状态。

    例如

       bindings = discountCustomerBindingRepo.bindingsForCode(discountCode);
       discountCode.isApplicableToOrder(..., bindings);
    

    虽然调用者可能传递了错误的绑定,但至少签名提醒必须检查此规则。我们牺牲了性能和一定程度上的完整性,以保持纯粹并且没有进程外依赖,这使得域更容易进行单元测试。

    2。完整性:尽可能少的域逻辑泄漏

    您可以让域通过提供 它带有允许它这样做的服务。

    例如

       discount.isApplicableToOrder(..., bindingsRepository);
    

    3.性能

    假设由于性能影响而无法将检查规则所需的数据加载到内存中,您可以使用接口后面的数据库查询来检查客户是否有资格获得折扣。

      // Pass service, favor completeness
      discount.isApplicableToOrder(..., customerEligibilityRule);
    
      // Will have an implementation in the infrastructure layer
      interface DiscountCustomerEligibilityRule {
          bool isEligibleForDiscount(Customer customer, DiscountCode discount);
      }
    

    或偏爱纯度,直接在应用服务中检查规则...

      bool eligible = customerEligibilityRule.isEligibleForDiscount(customer, discount) && discount.isApplicableToOrder(...);
    

    您通常更喜欢纯粹而不是完整性。有时即使看起来毫无意义,我只是将一个布尔值传递到表示规则结果的域中,以确保域客户端知道必须检查该规则,并且“代码到客户资格概念”在域中仍然可见。

    例如

    isApplicableToOrder(..., bool customerEligible) {
        return ... && customerEligible;
    }
    

    请注意,这一切都假设模型使用某种 ACL 来获得代码资格,但更多时候您可能会根据某些客户属性来确定资格。

    显然有很多方法可以解决这个问题,但希望我能给你一些启发!

    【讨论】:

      【解决方案2】:

      您可以将“验证来自该客户的此订单”建模为一个 saga,并拥有一个 DiscountPermissionForCustomer 聚合(使用“启用折扣”、“禁用折扣”等操作)。然后 saga 通过 DiscountCode 聚合执行 1-3,如果通过,则通过 DiscountPermissionForCustomer 聚合执行第 4 步。

      【讨论】:

        【解决方案3】:

        我们可以将规则 1-4 移动到允许创建的域服务中 数据库查询,但现在我们创建了一个贫血的域模型。

        我认为我不一定会因为我需要域服务来编排超出单个聚合范围的活动而得出我的域模型贫乏的结论。

        如果 DiscountCode 未能通过您已经在“isApplicableToOrder”中实现的验证之一,您似乎不想在数据库查询上浪费 I/O 来获取客户状态。

        因此,您的域服务可以加载 Order 和 DiscountCode 并通过调用 isApplicableToOrder 执行前三个验证,然后为客户配置文件加载专用聚合以检查客户权限或使用存储方法:

        Customer GetCustomerWithDiscountCodePermission(
            CustomerId customerId, 
            DiscountCode code);
        

        如果这返回一个客户,那么客户有权限,否则没有。

        根据测试 1-3 相对于其他测试 4 通过的可能性,在花费 I/O 加载订单聚合和折扣代码聚合以检查订单属性之前先执行客户检查甚至可能是有意义的.

        这是一个判断电话。我会先执行最有可能失败的检查,但无论哪种方式,我真的不认为使用域服务意味着一个贫乏的模型。

        【讨论】:

        • 如果将所有规则 1-4 移出到域服务中,DiscountCode 类绝对是一个贫乏的域模型。当然可以将规则 1-3 保留在 DiscountCode 类中,仅将规则 4 放入单独的域服务中,但是决定是否可以应用折扣的逻辑被拆分到多个类中,这使得它更难遵循并且可以可能会导致人们忘记检查规则 4。
        • @Grisu47 我无意暗示 1-3 被“移出”到域服务中。简单来说,Domain Service 可以是编排器,但它仍然会利用 DiscountCode 为步骤 1-3 公开的逻辑,然后调用步骤 4 的方法。Service 本身并不实际实现步骤 1-3,只是编排两个步骤(1-3 和 4)。
        【解决方案4】:

        除了Order 类型(它有一个CustomerUsableDiscountCodes 而不仅仅是一个CustomerId),以下简化类型(在F# 中是为了有趣和清晰)来自问题示例:

        type DiscountCode = { Id: DiscountCodeId; ... }
        type Item = { Id: ItemId; CanBeDiscounted: bool; ... }
        
        type Customer =
            { Id: CustomerId
              ...
              UsableDiscountCodes: List<DiscountCode> }
        
        type Order =
            { Items: List<Item>
              Customer: Customer }
        
        type IsExpired = DiscountCode -> bool
        type HasAlreadyBeenUsed = DiscountCode -> bool
        type CanItemBeDiscounted = Item -> bool
        type CanCustomerUseDiscountCode = Customer -> DiscountCode -> bool
        

        以这些类型为起点,如果我们看它们之间的依赖关系,DiscountCode 是最不依赖的类型,而Order 是最依赖的类型。即,DiscountCode 不需要知道任何事情(除了它自己),而Order 至少直接知道Items 和Customer 类型,而DiscountCode 是可传递的。以图形方式表示(上面是“知道”或“依赖”的元素,下面是“知道”的元素)可能如下所示:

                    ┌───────┐
            ┌───────┤ Order ├────────┐
            │       └───────┘        │
            ▼                        ▼
        ┌──────┐               ┌────────────┐
        │ Item │               │  Customer  │
        └───-──┘               └─────┬──────┘
                                     │
                ┌──────────────┐     │
                │ DiscountCode │◄────┘
                └──────────────┘
        

        鉴于此,Order(或“CheckOut”)子域可能是isDiscountCodeApplicableToOrder 函数更好的上下文。 Order子域调用DiscountCode子域确定规则1和3,然后直接确定规则2和4,因为它们涉及Items和Customer

        let isDiscountCodeApplicableToOrder order discountCode =
          (not (isExpired discountCode)) // Rule 1
          && (not (hasAlreadyBeenUsed discountCode)) // Rule 3
          && (List.forall canItemBeDiscounted order.Items) // Rule 2
          && canCustomerUseDiscountCode order.Customer discountCode // Rule 4
        

        这是一个纯函数,理想情况下应该是所有域逻辑。如果您真的需要延迟加载Customer,例如出于性能原因(如其他答案中所建议),您可以将getCustomer 函数作为依赖项传递;只有在所有其他检查都成功时才会调用此函数。例如,

        type Order' =
            { Items: List<Item>
              CustomerId: CustomerId }
        
        let isDiscountCodeApplicableToOrder' getCustomer order discountCode =
          (not (isExpired discountCode)) // Rule 1
          && (not (hasAlreadyBeenUsed discountCode)) // Rule 3
          && (List.forall canItemBeDiscounted order.Items) // Rule 2
          && canCustomerUseDiscountCode (getCustomer order.CustomerId) discountCode // Rule 4
        

        请注意,Order 是否持有 CustomerCustomerId 是函数类型中表现出来的类型依赖关系的次要问题,如图所示。

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 2011-08-02
          • 2011-08-02
          • 1970-01-01
          • 1970-01-01
          • 2020-02-16
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多