labs

Fact-based state in Rails

Maybe you learned this from experience or you joined a Rich Hickey-esque immutability craze, but one way or another you know state machines are complicated. They couple together a variety of meanings and easily grow out of control as your application gets more and more interesting. What can be done?

Lets examine a class from Railscast #392, demonstrating a declaration of Order’s state via aasm gem and it’s DSL syntax.

class Order < ActiveRecord::Base
  include AASM   scope :open_orders, -> { where(aasm_state: "open") }
  attr_accessor :invalid_payment

  aasm do
    state :incomplete, initial: true
    state :open
    state :canceled
    state :shipped

    event :purchase, before: :process_purchase do
      transitions from: :incomplete, to: :open, guard: :valid_payment?
    end

    event :cancel do
      transitions from: :open, to: :canceled
    end

    event :resume do
      transitions from: :canceled, to: :open
    end

    event :ship do
      transitions from: :open, to: :shipped
    end
  end

  def process_purchase
    # process order ...
  end

  def valid_payment?
    !invalid_payment
  end
end

Terse and syntax-sugery, but what’s completely missing from the code? A strategy to address:

  • Correcting/understanding unexpected states. How exactly did an object get into its state? And if it’s unexpected, what were its previous states? This situation is much much worse in production, where an inappropriate state goes by the name of data corruption and, as such, is one of the most costly failures with a slight chance of recovery.
  • Change. New business requirement makes an old state need to become two states, then two other states are grouped as one. What if you can now capture more data and introduce a ‘packaged’,’returned’,etc.? Or even simply rename a state? These are surpisingly nerve-wrecking changes to the system, necessitating at worst, a live update of entire database tables every time this happens.

State as a function of facts

To address both of the above issues with mutable state, one must simply turn the state machine inside-out and emphasize state’s complement – transitions. Transitions have a time-based nature in that they are ordered and have happened at a particular time. So they may also be known as events. Lets look at the above state machine to gather the states:

incomplete, open, cancelled, shipped

And emphasize the transition verbs:

purchase, cancel, resume, ship

Given a sequence of events, we can always produce a state. So lets gather events as nouns:

Purchase, Cancellation, Resumption, Shipment

class OrderEvent < ActiveRecord::Base
  belongs_to :order

  class Purchase < OrderEvent; end
  class Shipment < OrderEvent; end
  class Resumption < OrderEvent; end
  class Cancellation < OrderEvent; end
end

class Order < ActiveRecord::Base
  has_many :events, class_name: "OrderEvent"

  def state
    #play through events via state.transition(event)
    events.reduce(OrderState::Incomplete.new, &:transition)
  end

end

class OrderState
  def transition(event)
    Invalid.new
    #returning `self` would be another choice,
    #but failing early can be safer
  end

  class Incomplete < OrderState
    def transition(event)
      case event
      when OrderEvent::Purchase; Open.new
      else; Invalid.new
      end
    end
  end

  class Open < OrderState
    def transition(event)
      case event
      when OrderEvent::Cancellation; Cancelled.new
      when OrderEvent::Shipment;     Shipped.new
      else; Invalid.new
      end
    end
  end

  class Cancelled < OrderState
    def transition(event)
      case event
      when OrderEvent::Resumption; Open.new
      else; Invalid.new
      end
    end
  end

  class Shipped < OrderState
  end
  class Invalid < OrderState
  end
end

Verbose? Well, actually, this more verbose system with 3 major classes and inheritance is actually a much more flexible and error-proof solution to the state machine problem. Why does it work? Because it actually decouples incoming data(events) from our business interpretation of it. Events that happened – happened. It is the code of the system that makes sense of the past and guides the entry of new data. The concept of a state, is thus replayed from the history of past events:

o = Order.new
  o.events << OrderEvent::Purchase.new
  o.events << OrderEvent::Cancellation.new
  o.events << OrderEvent::Resumption.new
  o.events << OrderEvent::Cancellation.new
  o.events << OrderEvent::Resumption.new
  o.events << OrderEvent::Shipment.new
  o.state #<OrderState::Shipped:0x007facfa8a20d8>

No data migrations

You can introduce, remove and change states by changing only code. This is a significant improvement because each data migration is risky, difficult to debug and only gets worse with larger volumes of production data

Debugging unexpected states

There’s now full history of how an order got into a certain state and can thus be understood and debugged with a much larger hope of recovery.

And some would consider it a benefit to forego a DSL and meta-programming introduced by AASM or similar gems.

What do you think?

Concerned storing a little extra state will hurt runtime performance? Unconvinced of the simplicity of “state = f(past)” premise? Have taken this approach further? Let us know in the comments.