函数式 DDD 中命令处理程序和聚合方法之间的区别

Difference between command handlers and aggregate methods in functional DDD

遵循函数式编程范式,我有一个以事件源为主要持久性机制的 CQRS 架构。

目前我的聚合包括

命令处理程序执行

  1. 获取给定 aggregateId 的事件流
  2. 折叠当前聚合状态
  3. 根据当前状态应用一些业务逻辑
  4. 保留在步骤 3 中创建的任何事件

命令处理程序示例

type CommandHandler = (
  state: AggregateState,
  command: Command
) => E.Either<Err.Err, DomainEvent[] | void>;

基本上第 1、2 和 4 步被抽象为通用函数:

// pseudo-code
const wrapCommandHanler = (handler: CommandHandler) => {
  return wrapped = (command: Command) => {
    const events = fetchEvents();
    const state = applyReducer(events);
    const newEvents = handler(state, command);
    persistEvents(newEvents);
  }
}

所以我的命令处理程序非常精简和专注,只包含业务逻辑。

我阅读了 DDD,但给出的示例遵循 OOP 范例。在这些示例中,命令处理程序将调用聚合方法,其中聚合是包含状态和域逻辑的 class。

但在我的例子中,聚合状态和行为是分开的,我的命令处理程序是聚合行为。所以我的命令处理程序包含域逻辑。

我的问题: 这种方法是“正确的”/有效的 DDD 还是我用这个搬起石头砸自己的脚?如果不是,分离聚合函数和命令处理程序的主要目的是什么?

Is this approach "correct" / valid DDD or am I shooting myself in the foot with this?

DDD 基于两个原则:

  • 您应该在“域层”中使用 OOP 对您的业务逻辑 (BL) 进行建模
  • 域层包含 BL,整个 BL,只有 BL

通过将业务逻辑放入 reducer 中,您违反了 DDD 原则并取得了 anemic domain model。您的域确实非常贫乏,甚至没有使用 OOP 建模。这很重要,因为这样做会违反单一责任原则 (SRP),因为您的 reducer 有两项职责:将一系列事件转换为状态 验证业务规则。

If not, what is the main purpose of separating an aggregate function and a command handler?

与查询处理程序一样,命令处理程序实现了部分接口规范,位于应用程序层。它从客户端(命令)接收信息并进行一些低级别验证(例如,拒绝格式错误的消息或未经身份验证的请求)。命令处理程序然后调用其他层来完成它们的工作:用于事件存储访问的基础设施层、用于事件聚合转换的缩减器,以及用于业务规则验证和完整性的域层。这些处理程序中的代码是特定于应用程序的,因为同一域中的另一个应用程序将固有地具有不同的接口规范和不同的命令来处理。

聚合负责业务逻辑和业务规则。它是您试图操纵的实际概念的抽象。良好的领域建模尽量不了解应用程序,以提高可重用性。域模型可用于执行类似业务的多个应用程序。无论您实施的是药剂师在送药时使用的软件,还是医生开处方时使用的软件,您都可以使用相同的领域层对药物相互作用进行建模。在领域层中使用 OOP 允许使用非常简单的代码对非常复杂的业务逻辑进行建模。通过将业务逻辑放在一个单独的层中,您可以让一个小型开发人员团队与相关业务专家密切合作,对与一组应用程序相关的所有业务逻辑、约束和流程进行建模。您甚至可以对您的域进行单元测试。

请注意,您的方法完全可以接受,而且效率很高。为了做 DDD 而做 DDD 不是一个好习惯。做好 DDD 建模不是一件容易的事,应该被视为降低大型领域模型复杂性的一种手段。

您可能想要查看 Jérémie Chassaing's recent work on Decider

My question(s): Is this approach "correct" / valid DDD or am I shooting myself in the foot with this?

没关系 - 没有特别的理由要求您的功能设计与 2003 年左右的“Java 最佳实践”保持一致。

If not, what is the main purpose of separating an aggregate function and a command handler?

主要是为了在问题域(例如:“货运”)和“管道”的抽象之间创建一个清晰的边界——了解I/O、消息传递、数据库和事务的应用程序逻辑, HTTP等。

除其他外,这意味着您可以采用聚合“模块”(可以这么说)并将其移动到其他上下文,而不会干扰不同域函数之间的关系。

也就是说,没有什么神奇的事情发生 - 您可以重构您的“功能性”设计并创建一个略有不同的设计,从而为您提供与从“聚合”中获得的好处类似的好处。

在做纯函数式 DDD 时,命令(我故意不使用“对象”)对应于聚合的方法(如果使用类型,你可以说类型对应于接口,每个实例对应于一个调用;最终的处理函数对应于方法的主体)。

从技术上讲,如果是事件源,它是定义聚合的命令和事件处理程序的双人舞,尽管命令处理程序可能承担更多的负载。

Scala 中聚合的这两个定义实际上是同一件事。对于 OO-style,我正在使用更“持久状态”的方法,FP-style 是 event-sourced(OO-style event-sourced(聚合方法 return a Seq[Event] 并且您有一些定义事件处理程序的方法)和 FP-style durable-state(没有 EventHandler 和命令处理程序 returns a State ) 都是可以的,但是输入法感觉不自然)。两者等价于 unit-testable(event-sourced 可以说更重要,尤其是对于 property-based 测试):

// Note that Map here is an immutable Map (i.e. a value object)
// Domain has been simplified: assume that Item includes price and there are no discounts etc.

// OO and "durable state"-style persistence... application basically loads a cart from persistence, maps external commands into method calls, saves cart
class ShoppingCart(val itemCounts: Map[Item, Int], val checkedOut: Boolean = false) {
  def addItem(item: Item, qty: Int): Unit =
    // Collapsing the failed validations into a single do-nothing case
    if (!checkedOut && qty > 0) {
      itemCounts.get(item) match {
        case Some(count) =>
          itemCounts = itemCounts.updated(item, count + qty)

        case None =>
          itemCounts = itemCounts + (item -> qty)
      }
    }

  def adjustQtyOfItem(item: Item, newQty: Int): Unit =
    if (!checkedOut && itemCounts.contains(item)) {
      newQty match {
        case neg if neg < 0 =>
          // do nothing
          ()

        case pos if pos > 0 =>
          itemCounts = itemCounts.updated(item, newQty)

        case 0 =>
          itemCounts = itemCounts - item
      }
    }

  def removeAllOfItem(item: Item): Unit =
    adjustQtyOfItem(item, 0)

  def checkOut(): Unit =
    if (!checkedOut) {
      checkedOut = true
    }
}

// FP and event-sourced persistence
object ShoppingCart {
  case class State(itemCounts: Map[Item, Int], checkedOut: Boolean)

  sealed trait Command
  case class AddItem(item: Item, qty: Int) extends Command
  case class AdjustQtyOfItem(item: Item, newQty: Int) extends Command
  case object CheckOut extends Command

  val RemoveAllOfItem: Item => Command = AdjustQtyOfItem(_, 0)

  sealed trait Event
  case class ItemsAdded(item: Item, qty: Int) extends Event
  case class ItemsRemoved(item: Item, qtyRemoved: Int) extends Event
  case class AllOfItemRemoved(item: Item) extends Event
  case object CheckedOut extends Event

  val CommandHandler: (State, Command) => Seq[Event] = handleCommand(_, _)
  val EventHandler: (State, Event) => State = handleEvent(_, _)
  val InitialState = State(Map.empty, false)

  private def handleCommand(state: State, cmd: Command): Seq[Event] =
    if (!state.checkedOut) {
      cmd match {
        case AddItem(item, qty) if qty > 0 => Seq(ItemAdded(item, qty))
        case AdjustQtyOfItem(item, newQty) if state.itemCounts.contains(item)  && newQty >= 0 =>
          val currentQty = state.itemCounts(item)

          if (newQty > currentQty) {
            handleCommand(state, AddItem(item, newQty - currentQty))
          } else if (newQty == 0) {
            Seq(AllOfItemRemoved(item))
          } else {
            Seq(ItemsRemoved(item, currentQty - newQty))
          }

        case CheckOut => Seq(CheckedOut)
        case _ => Seq.empty
      }
    } else Seq.empty

  private def handleEvent(state: State, evt: Event): State =
    evt match {
      case ItemsAdded(item, qty) =>
        state.get(item)
          .map { prevQty =>
            state.copy(itemCounts = state.itemCounts.updated(item, prevQty + qty))
          }
          .getOrElse {
            state.copy(itemCounts = state.itemCounts + (item, qty))
          }

      case ItemsRemoved(item, qtyRemoved) =>
        state.get(item)
          .map { prevQty =>
            state.copy(itemCounts = state.itemCounts.updated(item, prevQty - qtyRemoved))
          }
          .getOrElse(state)

      case AllOfItemRemoved(item) =>
        state.copy(itemCounts = state.itemCounts - item)

      case CheckedOut =>
        state.copy(checkedOut = true)
    }
}

部分混淆可能源于“命令处理程序”在应用程序层中具有特定含义(它是来自外部的东西)并且在 event-sourced 聚合(应用程序)的上下文中具有稍微不同的含义event-sourced 应用程序中的层命令处理程序基本上只是一个 anti-corruption 层,将外部命令转换为针对聚合的命令(例如,针对聚合的命令可能不应该包含聚合的 ID:聚合知道它的 ID)).