A state machine can hold all possible states of something and the allowed transitions between these states.
For example, the state machine for a door would have only two states (open
and closed
) and only two transitions (opening
and closing
).
On the other hand, complex state machines can have several different states with hundreds of transitions between them. In fact, if you look around, you will notice finite state machines surrounding you - when you buy from a website, get a pack of crisps from the vending machine, or even just withdraw money from an ATM.
In this post, we’ll look at how to set up a state machine in Ruby and use the state machines gem.
When do we need a state machine in development? The simple answer is whenever you want to model multiple rules for state transitions or perform side-effects on some transitions.
The key here is to identify parts of your application that would benefit from a state machine.
A good example that always works for me is an Order
in the context of an e-commerce application.
For a simple application selling products online, an Order can be in one of several states:
created
processing
ready
shipped
delivered
void
You can see the allowed transitions in the following diagram.
Visualizing the order in the form of a state machine immediately allows us to clearly understand of the full flow, from ordering the product to delivery. And for us developers, it will enable clear, set steps on what can and cannot be done at particular points in that flow.
That being said, it is easy to jump down a rabbit hole by picking this up for a very wide problem and then end up with hundreds of states that are difficult to reason about and follow through. So always have a top-level idea and a state chart for your proposed state machine before implementing it.
Let’s try to implement our OrderStateMachine
with Ruby.
class OrderStateMachine
def initialize(order)
@order = order
@order.state = :created if @order.state.blank?
end
def mark_as_paid!
raise "Invalid state #{@order.state}" unless @order.created?
@order.state = :processing
end
def packed!
raise "Invalid state #{@order.state}" unless @order.processing?
@order.state = :ready
end
def shipped!
raise "Invalid state #{@order.state}" unless @order.ready?
@order.state = :shipped
end
# ...
end
That’s a simple enough implementation. For each possible transition in the state machine, we define a new method that does some sanity checks and performs the transition.
The great thing about the above implementation is that everything is explicit: any new developer can very quickly understand the full extent of the state machine.
Let’s see how we can add some side-effects to the transitions. We’ll automatically ship orders that are packed and deliverable:
class OrderStateMachine
# ...
def packed!
raise "Invalid state #{@order.state}" unless @order.processing?
@order.state = :ready
ship_order if @order.deliverable?
end
# ...
private
def ship_order
DeliveryService.create_consigment!(@order)
shipped!
end
end
While a naïve implementation works great, it is overly verbose, especially when we have a lot of transitions and conditions.
A quick check on Ruby Toolbox brings up several gems for state machines.
state_machines
and aasm
are the most popular ones, and both come with an ActiveRecord adapter if you want to use them with Rails.
Both are thoroughly tested and production-ready, so do check them out if you need to implement a state machine.
For this post, I will describe how we can model the state machine for our Order
using the state_machines
gem.
class Order
state_machine :state, initial: :created do
event :confirm_payment do
transition created: :processing
end
event :pack do
transition processing: :ready
end
event :cancel do
transition %i[created processing ready] => :void
end
event :return do
transition delivered: :void
end
event :ship do
transition ready: :shipped
end
event :fail_delivery do
transition shipped: :processing
end
end
end
The above class defines our simple state machine and all of its possible transitions. On top of this, it also automatically exposes a lot of utility methods on an order:
order.state # => "created"
order.state_name # => :created
order.created? # => true
order.can_pack? # => false (since payment has not been confirmed yet)
order.confirm_payment # => true (and transitions to `processing`)
order.can_pack? # => true
As is usual for any real-world system, many transitions in a state machine will come with some side effects.
The state_machines
gem makes it easy to define and execute them.
Let’s add two side-effects:
deliverable
.fail_delivery
events, send an email to the user notifying them of the event.class Order
attr_accessor :deliverable
state_machine :state, initial: :created do
# ...
after_transition to: :ready, do: :create_delivery_consignment, if: :deliverable
after_transition on: :fail_delivery, do: :notify_delivery_failure
end
private
def create_delivery_consignment
DeliveryService.create_consigment!(self)
ship
end
def notify_delivery_failure
UserMailer.delivery_failure_email(self).deliver_later
end
end
We can perform transitions as usual in the order:
order.deliverable = true
order.pack # => true
order.state # => "shipped" (through side-effect)
I would suggest checking out the Github page for the state_machines
gem to learn about all the possible options that the gem offers.
As discussed before, both state_machines
and aasm
support ActiveRecord.
When you use the state_machines
gem with ActiveRecord, not a lot of things change with the implementation. You can continue using most of what we’ve already discussed in the previous sections of this post.
Here are some additional features that you get:
use_transactions
if you want to disable this behavior.Like all other ActiveRecord callbacks, if you want to update the attributes of the current model from a transition, you must do this inside a before_transition
callback.
To update attributes inside after_transition
, you need to save the model again.
Check out the following example:
class Order < ActiveRecord::Base
state_machine initial: :created do
before_transition any => :processing do |order|
# You can just update the attribute here and it will be saved.
order.processing_start_at = Time.zone.now
end
after_transition any => :shipped do |order|
# Call update! to update the order.
# Just setting the attribute like above would not work here.
order.update!(shipped_at: Time.zone.now)
end
end
end
Order.with_state(:processing)
to find all the processing orders or Order.without_state(:processing)
to find all orders that are not under processing.If a state change is attempted without a matching transition (for example, from processing
to delivered
), the record will fail to save, and an error will be added to the validation errors.
To internationalize the generated error messages, just add some keys to provide translations for states and events inside the config files (for example, en.yml
):
en:
activerecord:
state_machines:
order:
states:
created: Created
processing: Under Processing
# ...
events:
return: Return Order
# ...
In this post, we explored why we’d use a state machine in development, before building a simple state machine. Finally, we looked at using the state machines gem in Ruby and Ruby on Rails.
Now that you have a basic understanding of a state machine, I am sure you will recognize several scenarios around you that already use a state machine or will benefit greatly from one.
Until next time, I wish you luck building state machines!
This article was originally posted on AppSignal Blog