Skip to content
Breadcrumb
Resources
GoCardless

Statesman: A modern, robust Ruby state machine

Andy Appleton
Written by

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 logo

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.

We're hiring developers

See job listing

Over 85,000 businesses use GoCardless to get paid on time. Learn more about how you can improve payment processing at your business today.

Get StartedLearn More

All Categories

PaymentsCash flowOpen BankingFinanceEnterpriseAccountingGoCardlessTechnology
Interested in automating the way you get paid? GoCardless can help
Interested in automating the way you get paid? GoCardless can help

Interested in automating the way you get paid? GoCardless can help

Contact sales