Statesman: A modern, robust Ruby state machine
Last editedJun 2024
There's no shortage of Ruby state machine libraries, but when we needed to implement a formal state machine we didn't find one which met all of our requirements:
- Easily composable with other Ruby objects. We need to define a state machine as a separate class and selectively apply it to our Rails models.
- DB level data integrity. We run multiple application servers, so state change related race conditions should be prevented by a database constraint.
- Full audit history of state transitions. We needed to persist transitions to the database and include unstructured metadata with each transition.
Today, we are pleased to release Statesman, the state machine library which we wish had existed.
Statesman is extremely opinionated and is designed to provide a robust audit trail and data integrity. It decouples the state machine logic from the underlying model and allows for easy composition with one or more model classes.
It provides the following features:
- Database persisted transitions for a full audit history.
- Database level transition duplication protection.
- Transition metadata - store any JSON along with a transition.
- Decoupled state machine logic
How it works
A state machine might look like this:
class PaymentStateMachine
include Statesman::Machine
# Define all possible states
state :pending, initial: :true
state :confirmed
state :paid
state :cancelled
# Define transition rules
transition from: :pending, to: [:confirmed, :cancelled]
transition from: :confirmed, to: :paid
end
This class expects a model instance and a transition class to be injected on instantiation. Let's assume we have ActiveRecord models set up like so:
class Payment < ActiveRecord::Base
has_many :payment_transitions
end
class PaymentTransition < ActiveRecord::Base
belongs_to :payment
end
We can set up a new state machine easily:
payment = Payment.find(some_id)
machine = PaymentStateMachine.new(payment, transition_class: PaymentTransition)
And call a few methods:
machine.current_state # => "pending"
machine.can_transition_to?(:confirmed) # => true
machine.transition_to!(:confirmed) # => true
machine.current_state # => "confirmed"
Under the hood statesman will create, associate and persist a new PaymentTransition
object. When we send current_state
to the machine
object it queries associated transitions and returns the state of the most recent. This is fully normalized, state is not stored on the parent Payment
model.
Guards & Callbacks
Often, the current state is not the only factor to determine whether a new state can be applied. Statesman supports guards which should return true
or false
, a false return value will prevent a transition.
# ...
guard_transition(from: :pending, to: :submitted) do |payment|
payment.passed_fraud_check?
end
# ...
# Assuming payment.passed_fraud_check? evaluates to false
machine.can_transition_to?(:confirmed) # => false
machine.transition_to!(:confirmed) # => Statesman::GuardFailedError
Callbacks are defined in the same way as guards and allow extra actions to be performed before and after a state transition:
#...
before_transition(to: :cancelled) do |payment, payment_transition|
payment.prepare_for_cancellation!
end
after_transition(to: :cancelled) do |payment, payment_transition|
PaymentMailer.cancelled_payment(payment.id).deliver
end
#...
Storage Adaptors
The examples show a Rails app using ActiveRecord, but there's also an adapter for Mongo, and an in-memory adaptor out of the box. An adaptor need only prove a small number of methods to be compatible so it should be very easy to implement for your favourite ORM. A set of Rspec examples are provided - pass these and your adapter should work just fine. Pull requests are very welcome!
Getting started
Statesman includes two rails generators - one to add columns to an existing transition class and one to create a new table and transition class. Both expect to be passed the parent model name and the transition model name:
$ rails generate statesman:active_record_transition Payment PaymentTransition
# => Creates a new transition model using the ActiveRecord adapter
$ rails generate statesman:mongoid_transition Payment PaymentTransition
# => Creates a new transition model using the Mongoid adapter
$ rails generate statesman:migration Payment PaymentTransition
# => Adds required attributes to an existing PaymentTransition model
Summary
We've been using Statesman in production for a while now and have been extremely happy with the results. We've removed hundreds of lines of ad-hoc state checking code and have really enjoyed the benefit of decoupling state machine logic from our data models.
Statesman is available via RubyGems and the source is on GitHub. Suggestions and contributions are very welcome, please open an issue or pull request, or just sent me a tweet @appltn.