State Machines in Ruby: An Introduction

An Introduction to Pattern Matching in Ruby

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.

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.

Our First State Machine in Ruby

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.

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.

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

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

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:

    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
  3. 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.
  4. 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
              # ...

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!


This article was originally posted on AppSignal Blog

Published 11 Jul 2022

I build mobile and web applications. Full Stack, Rails, React, Typescript, Kotlin, Swift
Pulkit Goyal on Twitter