在 DDD 中将具有副作用的业务逻辑放在哪里?

Where to put business logic with side effects in DDD?

假设我们的任务是实施一个 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:不能只用 DiscountCode class 检查规则 4,除非我们将所有允许的客户列表嵌入 DiscountCode class,如果有成千上万的客户,这是不可行的。同样,我们不能将允许的折扣代码列表嵌入到 Customer class 中,因为可能有很多。在数据库中,我们可以添加一个新的 table,其中包含有效客户 + 折扣代码元组:

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

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

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

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

We could move rule 1-4 into a domain service that is allowed to make database queries but now we have created an anemic domain model.

我认为我不一定会得出这样的结论:我有一个贫血的领域模型只是因为我需要一个领域服务来编排一个 activity 这超出了单个聚合的范围.

如果 DiscountCode 未通过您已在 'isApplicableToOrder' 中实施的验证之一,您似乎不想浪费 I/O 数据库查询来获取客户状态。 =12=]

因此,您的域服务可以加载 Order 和 DiscountCode 并通过调用 isApplicableToOrder 执行前三个验证,然后加载客户资料的专用聚合以进行检查客户许可或使用存储库方法:

Customer GetCustomerWithDiscountCodePermission(
    CustomerId customerId, 
    DiscountCode code);

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

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

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

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

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 来实现代码资格,但更常见的情况是您可能会根据某些客户属性来确定资格。

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

除了 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 至少直接知道 ItemCustomer 类型,而 DiscountCode 传递。以图形方式表示(其中“了解”或“依赖”的元素在上方,“已知”的元素在下方)可能如下所示:

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

鉴于此,Order(或可能是“CheckOut”)子域可能更适合 isDiscountCodeApplicableToOrder 功能。 Order子域调用DiscountCode子域确定规则1和3,然后直接确定规则2和4,因为它们涉及ItemCustomer

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 是函数类型中显示的类型依赖关系的次要点,如图所示。