Using state_machine with event sourced entities

Our event sourced aggregates usually have a lifecycle and they need to protect some business rules. Often they start with guard statements checking if performing given action is even allowed. I was wondering if there was a nice way to remove those conditional and make the code more explicit. I wanted to experiment with porting the code from our book to use state_machine gem and see if the results are promising.

My starting point was a class looking like this:

  class Order
    include AggregateRoot
    NotAllowed = Class.new(StandardError)
    Invalid    = Class.new(StandardError)

    def initialize(number:)
      @number         = number
      @state          = :draft
      @items          = []
    end

    def add_item(sku:, quantity:, net_price:, vat_rate:)
      raise NotAllowed unless state == :draft
      raise ArgumentError unless sku.to_s.present?
      raise ArgumentError unless quantity > 0
      raise ArgumentError unless net_price > 0
      raise ArgumentError if vat_rate < 0 || vat_rate >= 100

      # make changes and apply new state
    end

    def submit(customer_id:)
      raise NotAllowed unless state == :draft
      raise Invalid    if items.empty?

      # make changes and apply new state
    end

    def cancel
      raise NotAllowed unless [:draft, :submitted].include?(state)
      apply(OrderCancelled.strict(data: {
        order_number: number}))
    end

    def expire
      return if [:expired, :shipped].include?(state)
      apply(OrderExpired.strict(data: {
        order_number: number}))
    end

    def ship
      raise NotAllowed unless state == :submitted
      apply(OrderShipped.strict(data: {
        order_number: number,
        customer_id: customer_id,
      }))
    end

    private

    attr_reader :number, :state, :items, :fee_calculator, :customer_id

    def apply_strategy
      ->(_me, event) {
        {
          Orders::OrderItemAdded => method(:apply_item_added),
          Orders::OrderSubmitted => method(:apply_submitted),
          Orders::OrderCancelled => method(:apply_cancelled),
          Orders::OrderExpired   => method(:apply_expired),
          Orders::OrderShipped   => method(:apply_shipped),
        }.fetch(event.class).call(event)
      }
    end

    def apply_item_added(ev)
      # ...
    end

    def apply_submitted(ev)
      @state = :submitted
      @customer_id = ev.data[:customer_id]
    end

    def apply_cancelled(ev)
      @state = :cancelled
    end

    def apply_expired(ev)
      @state = :expired
    end

    def apply_shipped(ev)
      @state = :shipped
    end
  end

I removed some parts which are not interesting for this discussion.

As you can see, often methods start with some state checks.

Sometimes it’s one state that the Order must be in:

  def ship
    raise NotAllowed unless state == :submitted
    # ...
  end

Sometimes it’s two or more:

  def cancel
    raise NotAllowed unless [:draft, :submitted].include?(state)
    # ...
  end

Sometimes we want idempotency instead of an error:

  def expire
    return if [:expired, :shipped].include?(state)
    # ...
  end

So when you try to expire an already expired Order, we will do nothing. This can be in case our app is expiring in a reaction to a message coming from a message queue and we could get duplicated messages.

So I was wondering if using state_machine would make the code more readable and expressive. I didn’t even need to make the rules exactly the same. I just wanted to get a feeling how it could look like and If I enjoyed it more or less.

One of the things I noticed recently is that the more versions of code I have solving the same problem the better I understand their good and bad sides. I learn what works for me and what not.

So let’s see a version using state_machine

class Order
  include AggregateRoot
  NotAllowed = Class.new(StandardError)
  Invalid    = Class.new(StandardError)

  def initialize(number:)
    @number  = number
    @state   = 'draft'
    @items   = []
  end

  state_machine :state do
    state 'draft' do
      def add_item(sku:, quantity:, net_price:, vat_rate:)
        raise ArgumentError unless sku.to_s.present?
        raise ArgumentError unless quantity > 0
        raise ArgumentError unless net_price > 0
        raise ArgumentError if vat_rate < 0 || vat_rate >= 100

        # ...
      end

      def submit(customer_id:)
        raise Invalid if items.empty?

        # ...
      end
    end

    state 'submitted' do
      def ship
        apply(OrderShipped.strict(data: {
          order_number: number,
          customer_id: customer_id,
        }))
      end
    end

    state 'expired' do
      def expire; end
    end

    state 'cancelled' do
      def cancel; end
    end

    state all - %w(expired shipped) do
      def expire
        apply(OrderExpired.strict(data: {
          order_number: number
        }))
      end
    end

    state *%w(draft submitted) do
      def cancel
        apply(OrderCancelled.strict(data: {
          order_number: number
        }))
      end
    end
  end

It is interesting. If a method can be called in one state only you can define it inside that state definition:

  state 'submitted' do
    def ship
      # ...
    end
  end

If you try to call it in another state, you will get NoMethodError. On the other hand, the exception won’t be as good as our custom exception which clearly shows line number. It’s easier to have a look at that line of code and understand that a method was called in an invalid state compared to understanding a NoMethodError without any meaningful info.

It is possible to define a method in 2 states only:

  state *%w(draft submitted) do
    def cancel
      # ...
    end
  end

But the benefits are not so big in my opinion. You don’t see this method around other methods available for draft or submitted states and this meta-programming statement is not much better than my custom if statement.

I also experimented with:

  state all - %w(expired shipped) do
    def expire
      # ...
    end
  end

but that was the worst. Computers can understand that but for humans like me, it’s unbearable. I can’t easily recall all possible states in and remove expired and shipped from them, to understand where could that method be allowed or not.

Notice that I used only a very small subset of the gem’s features. That’s on purpose.

I looked into event transitions and transition callbacks but I could not find a nice way for them to play with the expectation that the only way to change a state of an event sourced object is via applying events.

I could define expire method that could transition into expired state:

  event :expire do
    transition all - %w(expired shipped) => :expired
  end

But that would set the state directly, instead of indirectly via a domain event that I would later save in a database.

    def expire
      return if [:expired, :shipped].include?(state)
      apply(OrderExpired.strict(data: {
        order_number: number}))
    end

So at the end, I decided that it’s probably not worth in many use-cases to use this particular gem with our way of writing event sourced entities. The benefits are small and most features of the library cannot be used easily without introducing problems.

Perhaps there is a different library out there for defining state machines that could play nicer with event sourcing and aggregate_root. But I haven’t found it yet. The struggle for a nice code involves a lot failed experiments.