ruby

State Machines in Ruby: An Introduction

Pulkit Goyal

Pulkit Goyal on

State Machines in Ruby: An Introduction

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.

State Machines in Development

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.

State machine diagram

Visualizing the order in the form of a state machine immediately allows us to clearly understand the full flow, from ordering the product to delivery. And for us developers, it enables 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.

Our First State Machine in Ruby

Let's try to implement our OrderStateMachine with Ruby.

rb
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:

rb
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.

Using the State Machines Gem in Ruby

For this post, I will describe how we can model the state machine for our Order using the state_machines gem.

rb
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:

rb
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

Execute Side-Effects with the State Machines Gem in Ruby

As is usual for any real-world system, many transitions in a state machine will come with side-effects. The state_machines gem makes it easy to define and execute them. Let's add two side-effects:

  1. On all transitions to ready, create a delivery consignment if the order is deliverable.
  2. On all fail_delivery events, send an email to the user notifying them of the event.
rb
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:

rb
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 the gem offers.

Use the State Machines Gem with ActiveRecord in Ruby on Rails

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:

  1. Side-effects happen inside a transaction. This means that if you do some database operations inside transition hooks and the transaction fails, the state change won't be committed. See use_transactions if you want to disable this behavior.
  2. 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:
rb
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
  1. The gem automatically provides scopes to filter models by their state. For example, you can do Order.with_state(:processing) to find all the processing orders or Order.without_state(:processing) to find all orders that are not under processing.
  2. 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):
yaml
en: activerecord: state_machines: order: states: created: Created processing: Under Processing # ... events: return: Return Order # ...

Side note: Check out this post to troubleshoot ActiveRecord performance issues.

Wrap Up: Build State Machines in Ruby

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!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Pulkit Goyal

Pulkit Goyal

Our guest author Pulkit is a senior full-stack engineer and consultant. In his free time, he writes about his experiences on his blog.

All articles by Pulkit Goyal

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps