Finite state machines in Rails
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.
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
state
s defined i.eemail_verification
, etc, to create predicate helper methods in the model, so you can do something likeuser.email_verification?
to see if the user is currently in theemail_verification
state.Use the
event
s defined to create helper methods (2 each) for conveniently changing the state, for example, we will getemail_verified
andemail_verified!
instance methods to transition the user from theemail_verification
state to the next, which isaddress_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 variousevents
defined and disallow direct state updates e.guser.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...