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 provides a coin-operated turnstile as an example of state machine:
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
coin transition when the state is
unlocked. Both do not change
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:
:bonus. The naïve FSM
would look like:
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
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 (
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.
The real issue is: our workflow is not robust. There is a transition we
missed: noop from
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.
Should not we also introduce a particular state for “sending an email”?
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
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.