A state machine is an interesting design pattern.
The state_machine gem is a great library. It provides a nice structure to declare states and transitions. It also provides nice callback hooks that can be utilized to run specific actions before or after a transition happens.
It has hooks into
ActiveRecord and saves the model when the transition from
one state to another is successful. The gem also works just fine with plain old
ruby objects as well. Here is a simple example of a state machine.
class Ticket < ActiveRecord::Base state_machine :state, :initial => :stopped do state :stopped state :started event :start do transition :stopped => :started end event :stop do transition :started => :stopped end end end
To interact with the state machine, you can do the following:
ticket = Ticket.create(subject: 'New issue with post', content: 'Some text') ticket.start #=> true ticket.state #=> 'started' ticket.start #=> false ticket.state #=> 'started' ticket.stop #=> true ticket.state #=> 'stopped'
Earlier in Dumb Data Objects I talked very briefly about how to integrate a state machine into a Dumb Data Object. What this gem brings to the table is another way back into “Callback Hell”, and that is something everyone should avoid.
The transition callbacks are there to be used sparingly. If the need arises to apply a callback to the state machine, think about the implications this can have on the object through out its lifetime. Will this callback make interaction easier or will it complicate the model and make it difficult for someone else to pick up?
One callback that is typically rife with code smell is the
callback. This callback will trigger an action after a transition from one state
to another has taken place. Why not just execute that method after the event was
triggered? It’s very easy to do.
State machines are supposed to be simple. If transitions become complex, then the state machine flow becomes disruptive and difficult to ascertain. The ultimate goal is to move crazy logic out of the model and push that off into service objects and command objects like the following:
class StartTicket def initialize(ticket) @ticket = ticket end def execute if @ticket.start notify = NotifyTicketSubscribers.new(@ticket) notify.send_ticket_started_email true else false end end end
If I were to mix in the Signals Gem and implement a command pattern, it would look like the following:
class StartTicket include Signals::Publisher def initialize(ticket) @ticket = ticket end def execute if @ticket.start broadcast(:start_ticket_successful, @ticket) else broadcast(:start_ticket_failed, @ticket) end end end
StartTicket becomes really easy at this point.
# Use 'rspec', >= '2.14.0.rc1' in order to # utilize test spies describe StartTicket do describe '#execute' do it 'should start the ticket' do # Mock ticket = double('Ticket', start: true) command = StartTicket.new(ticket) command.stub(broadcast: true) # Excercise command.execute # Verify command.should( have_received(:broadcast). with(:start_ticket_successful, ticket) ).once ticket.should have_received(:start).once end end end
after_transition callbacks will be pushed off onto the listeners and
before_transition calls will be done before the event is ever triggered in the
class TicketListener include Signals::Subscriber listen_for :start_ticket_successful => :send_ticket_started_email listen_for :stop_ticket_successful => :send_ticket_stopped_email def send_ticket_started_email(ticket) # Send out emails end def send_ticket_stopped_email(ticket) # Send out emails end end
Piecing it all together, results in this simple use case.
ticket = Ticket.find(1) command = StartTicket.new(ticket) command.subscribe(TicketListener.new) command.execute
Yes there is a lot of boiler-plate code, but trust me when I say the benefits greatly out weighs the drawbacks.
For the love that is programming and refactoring, stay away from callbacks!