Finite state machines in Rails

Dec 10, 2021
5 mins

What is a finite state machine (FSM)?

According to Wikipedia, a FSM is a mathematical model of computation. It is an abstract machine that can be in exactly one of a finite number of states at any given time. Each change from one state to another is called a transition.

The idea from FSMs can be applied to many things (traffic lights, elevators, etc) but we'll be looking at its application to building software. It's really a lot simpler than it appears.

Practical Example

Imagine we're building an e-commerce application where users can add products to cart and checkout. After signing up, before users can add products to cart, we want to make sure that they are real users. One way to do that is through email verification. Secondly, before we allow users to complete checkout, we want to make sure they have a valid address (since we'll be shipping the products to them).

So, given our example, a user could be in one of 3 states - Email Verification, Address Verification, and Complete. At any point in time, the state the user is in determines the things they are allowed to do in the application (browse the site, add to cart, and/or checkout).

Here's a visual illustration of the possible state transitions the user can go through.

state_transitions

Notice that the state transitions are in a specific order. A user going from the Email Verification state to Complete for example is an invalid transition that we should not allow. Same with going from Address Verification to Email Verification state. We want to enforce state transitions in a specific order and make sure to avoid invalid transitions. This is where a FSM comes in.

Let's implement this example in Rails.

Application in Rails

We're going to take a look at a typical example using:

  • Rails 6.1
  • Postgres database

1. A model

Let's assume in our app, we have a simplified User model that looks like this:

class User < ApplicationRecord
  validates :email, presence: true, uniqueness: true
  validates :name, presence: true
end

2. Install a FSM gem

We're going to make use of the aasm gem, which literally means acts as state machine.

Add this to your Gemfile:

gem 'aasm', '~> 5.2' # for, you know, state transitions

Install the gem:

bundle install

3. Add/Ensure a state field

Your model should have a sort of state field that stores the current state. Our User model doesn't have one so we'll add a migration to add state field.

rails generate migration add_state_to_users state:string

That should create a migration that looks like this:

class AddStateToUsers < ActiveRecord::Migration[6.1]
  def change
    add_column :users, :state, :string
  end
end

Run the migration:

rails db:migrate 

4. Configure the state transitions

We will modify our User model and define our state transitions. Note: We used the database field state, but could have been named anything else as long as it's descriptive (e.g status)

class User < ApplicationRecord
  include AASM # we include the AASM module

  validates :email, presence: true, uniqueness: true
  validates :name, presence: true

  aasm no_direct_assignment: true, column: 'state' do
    # we define all the states
    state :email_verification, initial: true
    state :address_verification, :complete

    #  valid state transitions
    event :email_verified do
      transitions from: :email_verification, to: :address_verification
    end

    event :address_verified do
      transitions from: :address_verification, to: :complete
    end

    event :needs_address_verification do
      transitions from: :complete, to: :address_verification
    end
  end
end

AASM will:

  • Use the states defined i.e email_verification, etc, to create predicate helper methods in the model, so you can do something like user.email_verification? to see if the user is currently in the email_verification state.

  • Use the events defined to create helper methods (2 each) for conveniently changing the state, for example, we will get email_verified and email_verified! instance methods to transition the user from the email_verification state to the next, which is address_verification. Note: the bang (!) equivalents immediately writes the update to the database, while the non-bang methods just update the field without persisting (in case you have more updates to do before saving to the database).

  • The no_direct_assignment: true configuration tells AASM to only allow state updates through the various events defined and disallow direct state updates e.g user.update(state: "complete"). I recommend it as it ensures predictable state updates.

5. Testing our work

Now it's time to test our state transitions in Rails console.

# Find or create a user
user = User.first || User.create(email: "jane@example.com", name: "Jane Doe")

user.state # => "email_verification"
user.email_verification? # => true

# Invalid transition: from "email_verification" to "complete"
user.address_verified # => throws an AASM::InvalidTransition error

# Valid transitions
user.email_verified! # => from "email_verification" to "address_verification"
user.address_verification? # => true

user.address_verified! # => from "address_verification" to "complete"
user.complete? # => true

user.needs_address_verification! # => from "complete" to "address_verification"
user.address_verification? # => true

# No direct field update
user.update!(state: "complete") # => throws AASM::NoDirectAssignmentError

The "needs_address_verification!" is needed so that if the user changes their address, we can transition the state back to address_verification until they actually verify the new address.

Should you always use a FSM?

As a general rule of thumb, whenever you have a model with a field that would be storing some sort of state values where the states could transition from one to another from a possible set of states, it might be an indicator to reach for a FSM library, rather than manually updating the field.

A FSM:

  • Enforces predictable state transitions
  • Avoids invalid/accidental state transitions
  • Avoids typos in state names

As always, there's no one-rule-fits-all; make decisions depending on your specific use case.

Conclusion

We learned about finite state machines and saw a simple practical example in a Rails application using the AASM gem. There are other advanced configurations like Callbacks (running code before/after a transition), Guards (conditions to ensure before a successful state transition), etc. I recommend diving deeper into the gem's documentation to learn more.

Happy coding :)

Comments

Related Posts

Memoization Techniques in Ruby

Let's explore memoization and how we can employ it in Ruby to speed up repeatable time-consuming operations...

Jan 16, 2022