Workflow is a finite-state-machine-inspired API for modeling and interacting with what we tend to refer to as ‘workflow’.
A lot of business modeling tends to involve workflow-like concepts, and the aim of this library is to make the expression of these concepts as clear as possible, using similar terminology as found in state machine theory.
So, a workflow has a state. It can only be in one state at a time. When a workflow changes state, we call that a transition. Transitions occur on an event, so events cause transitions to occur. Additionally, when an event fires, other random code can be executed, we call those actions. So any given state has a bunch of events, any event in a state causes a transition to another state and potentially causes code to be executed (an action). We can hook into states when they are entered, and exited from, and we can cause transitions to fail (guards), and we can hook in to every transition that occurs ever for whatever reason we can come up with.
Now, all that’s a mouthful, but we’ll demonstrate the API bit by bit with a real-ish world example.
Let’s say we’re modeling article submission from journalists. An article is written, then submitted. When it’s submitted, it’s awaiting review. Someone reviews the article, and then either accepts or rejects it. Explaining all that is a pain in the arse. Here is the expression of this workflow using the API:
Workflow.specify 'Article Workflow' do state :new do event :submit, :transitions_to => :awaiting_review end state :awaiting_review do event :review, :transitions_to => :being_reviewed end state :being_reviewed do event :accept, :transitions_to => :accepted event :reject, :transitions_to => :rejected end state :accepted state :rejected end
Much better, isn’t it!
The initial state is :new
– in this example that’s somewhat meaningless. (?) However, the :submit
event :transitions_to => :being_reviewed
. So, lets instantiate an instance of this Workflow:
workflow = Workflow.new('Article Workflow') workflow.state # => :new
Now we can call the submit event, which transitions to the :awaiting_review
state:
workflow.submit workflow.state # => :awaiting_review
Events are actually instance methods on a workflow, and depending on the state you’re in, you’ll have a different set of events used to transition to other states.
Given this workflow is now :awaiting_approval
, we have a :review
event, that we call when someone begins to review the article, which puts the workflow into the :being_reviewed
state.
States can also be queried via predicates for convenience like so:
workflow = Workflow.new('Article Workflow') workflow.new? # => true workflow.awaiting_review? # => false workflow.submit workflow.new? # => false workflow.awaiting_review? # => true
Lets say that the business rule is that only one person can review an article at a time – having a state :being_reviewed
allows for doing things like checking which articles are being reviewed, and being able to select from a pool of articles that are awaiting review, etc. (rewrite?)
Now lets say another business rule is that we need to keep track of who is currently reviewing what, how do we do this? We’ll now introduce the concept of an action by rewriting our :review
event.
event :review, :transitions_to => :being_reviewed do |reviewer| # store the reviewer somewhere for later end
By using Ruby blocks we’ve now introduced extra code to be fired when an event is called. The block parameters are treated as method arguments on the event, so, given we have a reference to the reviewer, the event call becomes:
# we gots a reviewer workflow.reivew(reviewer)
OK, so how do we store the reviewer? What is the scope inside that block? Ah, we’ll get to that in a bit. An instance of a workflow isn’t as useful as a workflow bound to an instance of another class. We’ll introduce you to plain old Class integration and ActiveRecord integration later in this document.
So we’ve covered events, states, transitions and actions (as Ruby blocks). Now we’re going to go over some hooks you have access to in a workflow. These are on_exit, on_entry and on_transition.
When states transition, they are entered into, and exited out of, we can hook into this and do fancy junk.
state :being_reviewed do event :accept, :transitions_to => :accepted event :reject, :transitions_to => :rejected on_exit do |new_state, triggering_event, *event_args| # do something related to coming out of :being_reviewed end end state :accepted do on_entry do |prior_state, triggering_event, *event_args| # do something relevant to coming in to :accepted end end
Now why don’t we just put this code into an action block? Well, you might not have only one event that transitions into a state, you may have multiple events that transition to a particular state, so by using the on_entry and on_exit hooks you’re guaranteeing that a certain bit of code is executed, regardless what event fires the transition.
Billy Bob the Manager comes to you and says “I need to know EVERYTHING THAT HAPPENS EVERYWHERE AT ANY TIME FOR EVERYTHING”. For whatever reasons you have to record the history of the entire workflow. That’s easy using on_transition.
on_transition do |from, to, triggering_event, *event_args| # record everything, or something end
Workflow doesn’t try to tell you how to store your log messages, (but we’d suggest using a *splat and storing that somewhere, and keep your log messages flexible).
Finite state machines have the concept of a guard. The idea is that if a certain set of arbitrary conditions are not fulfilled, it will halt the transition from one state to another. We haven’t really figured out how to do this, and we don’t like the idea of going :guard => Proc.new {}
, coz that’s a bit lame, so instead we have halt!
The halt!
method is the implementation of the guard concept. Let’s take a look.
state :being_reviewed do event :accept, :transitions_to => :accepted do halt if true # does not transition to :accepted end end
Inline with how ActiveRecord does things, halt!
also can be called via halt
, which makes the event return false, so you can trap it with if workflow.event instead of using a rescue block. Using halt returns false.
# using halt workflow.state # => :being_reviewed workflow.accept # => false workflow.halted? # => true workflow.state # => :being_reviewed # using halt! workflow.state # => :being_reviewed begin workflow.accept rescue Workflow::Halted => e # we gots an exception end workflow.halted? # => true workflow.state # => :being_reviewed
Furthermore, halt!
and halt
accept an argument, which is the message why the workflow was halted.
state :being_reviewed do event :accept, :transitions_to => :accepted do halt 'coz I said so!' if true # does not transition to :accepted end end
And the API for, like, getting this message, with both halt
and halt!
:
# using halt workflow.state # => :being_reviewed workflow.accept # => false workflow.halted? # => true workflow.halted_because # => 'coz I said so!' workflow.state # => :being_reviewed # using halt! workflow.state # => :being_reviewed begin workflow.accept rescue Workflow::Halted => e e.halted_because # => 'coz I said so!' end workflow.halted? # => true workflow.state # => :being_reviewed
We can reflect off the workflow to (attempt) to automate as much as we can. There are two types of reflection in Workflow - reflection and meta-reflection. We’ll explain the former first.
workflow.states # => [:new, :awaiting_review, :being_reviewed, :accepted, :rejected] workflow.states(:new).events # => [:submit] workflow.states(:being_reviewed).events # => [:accept, :reject] workflow.states(:being_reviewed).events(:accept).transitions_to # => :accepted
Meta-reflection allows you to add further information to your states, events in order to allow you to build whatever interface/controller/etc you require for your application. If reflection were Batman then meta-reflection is Robin, always there to lend a helping hand when Batman just isn’t enough.
state :new, :meta => :ui_widget => :radio_buttons do event :submit, :meta => :label => 'Upload...' end
And as per the last example, getting yo meta is very similar:
workflow.states(:new).meta # => {:ui_widget => :radio_buttons} workflow.states(:new).meta[:ui_widget] # => :radio_buttons workflow.states(:new).meta.ui_widget # => :radio_buttons workflow.states(:new).events(:submit).meta # => {:label => 'Upload...'} workflow.states(:new).events(:submit).meta[:label] # => 'Upload...' workflow.states(:new).events(:submit).meta.label # => 'Upload...'
Thankfully, meta responds to each so you can iterate over your values if you’re so inclined.
workflow.states(:new).meta.each { |key, value| puts key, value }
The order of which things are fired when an event are as follows:
* action * on_transition (if action didn't halt) * on_exit * WORKFLOW STATE CHANGES, i.e. transition * on_entry
Note that any event arguments are passed by reference, so if you modify action arguments in the action, or any of the hooks, it may affect hooked fired later.
We promised that we’d show you how to integrate workflow with your existing classes and instances, let look.
class Article include Workflow workflow do state :new do event :submit, :transitions_to => :awaiting_review end state :awaiting_review do event :approve, :transitions_to => :approved end state :approved # ... end end article = Article.new article.state # => :new article.submit article.state # => :awaiting_review article.approve article.state # => :approved
And as ActiveRecord is all the rage these days, all you need is a string field on the table called “workflow_state”, which is used to store the current state. Workflow handles auto-setting of a state after a find, yet it doesn’t save a record after a transition (though you could make it do this in on_transition).
class Article < ActiveRecord::Base include Workflow workflow do # ... end end
When integrating with other classes, behind the scenes, Workflow sets up a Proxy to method missing. A probable common error would be to call an event that doesn’t exist, so we catch NoMethodError
‘s and helpfully let you know what available events exist:
class Article include Workflow workflow do state :new do event :submit, :transitions_to => :awaiting_review end state :awaiting_review do event :approve, :transitions_to => :approved end state :approved # ... end end article = Article.new article.aaaa NoMethodError: undefined method `aaaa' for #<Article:0xe4e8>, conversely, if you were looking to call an event for its workflow, you're in the :new state, and the available events are [:submit]
So just incase you screw something up (like I did while testing this library), it’ll give you a useful message.
You can define multiple events of the same name, for example if you wish to transition to different states depending on conditions. The nicer way to have done this may well have been to have it as an array with a conditions hash, or proc, or something else! I’ve done it this way to keep it consistent with the feel of the rest of the plugin..
Take this state definition (taken from my additional workflow authorization class for restful_authentication):
state :suspended do event :delete, :transitions_to => :deleted event :unsuspend, :transitions_to => :active do fall_through if activated_at.blank? end event :unsuspend, :transitions_to => :pending do fall_through if activation_code.blank? end event :unsuspend, :transitions_to => :passive end
When a user is in the suspended state then the unsuspend event needs to return them to the correct state. In the case where multiple events of the same name exist for a given state then they are executed in the order in which they appear. In this example, the first event will transition from :suspended to :active - unless the condition for fall_through is met. If the fall_through condition is met, execution drops through to the next event without any transition occuring. You can also still use halt - where halt is used no further events will be processed and the halted_because result will be the first and only reason for halting. If you keep falling_through without a catch-all then no transition will occur.
You can blatter existing workflows, by simply opening them up again (similar to how Ruby works!).
Workflow.specify 'Blatter' do state :opened do event :close, :transitions_to => :closed end state :closed end workflow = Workflow.new('Blatter') workflow.close workflow.state # => :closed workflow.open # => raises a (nice) NoMethodError exception! Workflow.specify 'Blatter' do state :closed do event :open, :transitions_to => :opened end end workflow.open workflow.state # => :opened Workflow.specify 'Blatter' do state :open do event :close, :transitions_to => :jammed # the door is now faulty :) end state :jammed end workflow.close workflow.state # => :jammed
Why can we do this? Well, we needed it for our production app, so there.
And that’s about it. A update to the implementation may allow multiple workflows per instance of a class or ActiveRecord, but we haven’t figured out if that’s required or appropriate
Ryan Allen, March 2008.