Workflow as FSM

A finite-state machine (FSM) or finite-state automaton (FSA, plural: automata), finite automaton, or simply a state machine, is a mathematical model of computation. It is an abstract machine that can be in exactly one of a finite number of states at any given time.
The FSM can change from one state to another in response to some external inputs; the change from one state to another is called a transition.
An FSM is defined by a list of its states, its initial state, and the conditions for each transition.
Wikipedia

Wikipedia provides a coin-operated turnstile as an example of state machine:

State diagram for a turnstile

Even from this example it’s clear that the definition above is not accurate. To be precise, the statement “the change from one state to another is called a transition” is actually incomplete. Check the push transition when the state is locked and coin transition when the state is unlocked. Both do not change the state.

This terminology inaccuracy leads to misunderstanding of how FSM should be built to become a friend of a developer, not an enemy to fight against.

Bonus program tracking (naïve approach)

Let’s imagine we have a bonus program to be developed for our clients. Each client receives a fixed number of bonus points for each subsequent action, and, as soon as the total amount reaches some value, the customer is awarded with something fruitful. We want to implement bonus as a model, evidently having three states: :initial, :collecting and :bonus. The naïve FSM would look like:

Naïve FSM

Each subsequent bonus point moves us towards :bonus state. The code, supporting this workflow could look somewhat like below (I am using workflow gem here, but it really does not matter.)

workflow do
  state :initial do
    event :gain, transitions_to: :collecting
  end
  state :collecting do
    event :bonus_reached, transitions_to: :bonus
  end
  state :bonus do
    event :restart, transitions_to: :initial
  end
end

def coins_gained!(amount)
  @coins = @coins.to_i + amount
  if @coins >= 100
    bonus_reached!
    @coins -= 100
    restart!
  end
end

Right?—Unfortunately, no. This is not a robust workflow. It shifts too much of it’s duties elsewhere. It does not control intermediate state (:collecting) properly. Imagine, two days after launch we were asked by our marketing department to email customers when their points amount reaches 20 and 50 points. What would we do?—Yes, we would blow our coins_gained! method up:

def coins_gained!(amount)
  old_amount = @coins.to_i
  new_amount = old_amount + amount

  if old_amount < 20 && new_amount >= 20 ||
     old_amount < 50 && new_amount >= 50
    send_email_to_customer
  end

  @coins = new_amount
  if @coins >= 100
    bonus_reached!
    @coins -= 100
    restart!
  end
end

Yeah, yeah, I know that the whole checking and sending email might be extracted to it’s own method, as we always do in Rails making our models finally to contain a dozillion of private helpers calling other private helpers to satisfy rubocop with her 10-LOC-per-method-body limit.

Better approach

The real issue is: our workflow is not robust. There is a transition we missed: noop from :collecting to :collecting state:

Proper FSM

The workflow transitions are now named properly in the first place.

workflow do
  state :initial do
    event :gain, transitions_to: :collecting
  end
  state :collecting do
    event :gain, transitions_to: :collecting
    event :bonus_reached, transitions_to: :bonus
  end
  state :bonus do
    event :restart, transitions_to: :initial
  end
end

Better?—Yes, but still not perfect:

def gain(amount)
  @coins = @coins.to_i + amount
  bonus_reached! if @coins >= 100
end

def bonus_reached
  @coins -= 100
  restart!
end

def on_transition(amount)
  send_email_to_customer if need_email? # encapsulated check
end

The code is now clean, no explicit if on checking what state to be next exists, the email will be sent despite the target state. What could be improved?—Well, sending email is also a state.

Overdesigned approach

Should not we also introduce a particular state for “sending an email”?

Overdesigned FSM

The answer is: no, we probably shouldn’t. It will just make things cumbersome, it will implicitly require more unnecessary checks, it will make the transitions less explicit:

def gain(amount)
  ...
  emailing! if need_email?
  # what if bonus is reached?
  ...
end

def emailing
  send_email_to_customer
  email_sent!
  # back to collecting ⇒ void transition implies
  #                      potential glitches when
  #                      handlers are implemented
end

Conslusion

When some action is taken place from outside, it should be covered with transition. Even if it’s same-state transition.

When the action is completely internal, it does not deserve it’s state.




BEAM Bloggers Webring