基于状态机实现的DDD聚合根Order对象


展示一段示例代码 - Order 类。此类在我们的示例 DDD/CQRS/ES 应用程序中使用。我们正在改进此应用程序,因此这是记录某些意见和更改的好机会。

module Ordering
  class Order
    include AggregateRoot

    AlreadySubmitted = Class.new(StandardError)
    AlreadyPaid = Class.new(StandardError)
    NotSubmitted = Class.new(StandardError)
    OrderHasExpired = Class.new(StandardError)
    MissingCustomer = Class.new(StandardError)

    def initialize(id)
      @id = id
      @state = :draft
    end

    def submit(order_number, customer_id)
      raise AlreadySubmitted if @state.equal?(:submitted)
      raise OrderHasExpired  if @state.equal?(:expired)
      raise MissingCustomer unless customer_id
      apply OrderSubmitted.new(data: {order_id: @id, order_number: order_number, customer_id: customer_id})
    end

    def confirm(transaction_id)
      raise OrderHasExpired if @state.equal?(:expired)
      raise NotSubmitted unless @state.equal?(:submitted)
      apply OrderPaid.new(data: {order_id: @id, transaction_id: transaction_id})
    end

    def expire
      raise AlreadyPaid if @state.equal?(:paid)
      apply OrderExpired.new(data: {order_id: @id})
    end

    def add_item(product_id)
      raise AlreadySubmitted unless @state.equal?(:draft)
      apply ItemAddedToBasket.new(data: {order_id: @id, product_id: product_id})
    end

    def remove_item(product_id)
      raise AlreadySubmitted unless @state.equal?(:draft)
      apply ItemRemovedFromBasket.new(data: {order_id: @id, product_id: product_id})
    end

    def cancel
      raise OrderHasExpired if @state.equal?(:expired)
      raise NotSubmitted unless @state.equal?(:submitted)
      apply OrderCancelled.new(data: {order_id: @id})
    end

    on OrderSubmitted do |event|
      @customer_id = event.data[:customer_id]
      @number = event.data[:order_number]
      @state = :submitted
    end

    on OrderPaid do |event|
      @state = :paid
    end

    on OrderExpired do |event|
      @state = :expired
    end

    on OrderCancelled do |event|
      @state = :cancelled
    end

    on ItemAddedToBasket do |event|
    end

    on ItemRemovedFromBasket do |event|
    end
  end
end

一如既往,这个类一开始很好很简单,但随着时间的推移,它变得越来越不可读。
这个类现在有几个职责。其中一些我将留待另一次讨论(例如将域代码与事件代码耦合)。

今天我想重点讨论状态机的概念。

此 Order 对象现在有 5 种可能的状态。怎么长到这么大了?
如果我们进一步扩展增长状态会发生什么?

状态机:

  1. draft
  2. submitted
  3. paid
  4. expired
  5. cancelled

draft是初始状态。然后幸福路径切换到submitted,然后切换到paid
不太愉快的路径包括expired和cancelled,两者都是叶状态。

问题:
状态机的挑战在于,以可读的方式在代码中表示它们并不容易。每当状态和转换的数量增加时,阅读此类代码就会变得更加困难。

状态机由状态和转换组成。我们需要以某种方式在代码中表示它们。在这个实现中,我们把过渡作为主要的“维度”。方法名称显示了可能的转换。然而,它们显示了所有状态的可能转变。这导致了一个问题,在每种方法中,我们现在都需要“禁用”不可能的转换。在这种情况下,我们可以通过提前返回而不使用异常来做到这一点。这段代码的问题在于,很难轻易地说出这个状态机中可能的流程是什么。该代码受到其他职责的影响,使其可读性降低。

为什么我们在这里使用异常?
因为该对象的职责之一是传达“为什么”某种更改是不可能的。提前返回仅传达布尔信息 - 可能或不可能。自定义异常会带来更多上下文。

这里有哪些可能的改进方向?

  • 减小该状态机的大小
  • 将对象解耦以解释为什么无法从代码中进行更改,而代码只是说不可能
  • 根据可能的状态提取一个新对象
  • 从此类中提取事件逻辑