Statesman: A modern, robust Ruby state machine
Last editedJan 2020
- 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
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
Often, the current state is not the only factor to determine whether a new state can be applied. Statesman supports guards which should return
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 #...
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!
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
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.