Skip to content

mkcode/statesmin

 
 

Repository files navigation

Statesmin

Build Status

Statesmin is a fork of stateman that uses a machete to rip out all of the database related code leaving you with a simple, robust, and well tested DSL for defining state machines in your application.

When to use statesmin over statesman:

  • You wish to manage an object's current state yourself, including not persisting it at all.
  • You have custom requirements for your transition log entries.
  • You need multiple (and very different) transition processes.
  • You enjoy and habitually write service objects with small scopes.
  • You will be frequently updating the state of an object and you can expect the transitions log to contain a lot of entries.

If any of the above apply to your application, then consider using statesmin. In addition to defining your state machines, statesmin also requires you to:

  • Persist the current state of the object(s) yourself.
  • Instantiate a state machine with the object's current state yourself.
  • Maintain an transition / audit log yourself (if required)
  • Define a custom transition process yourself.

All in all, statesmin takes considerably more work to get setup and running than statesman, so statesman is recommended if you need to get a state machine setup and running without any special requirements or concerns.

Working with Statesmin::Machine

Defining a state machine uses the same DSL as statesman. See tldr-usage for a more complete example.

class OrderStateMachine
  include Statesmin::Machine

  state :pending, initial: true
  state :checking_out
  state :purchased
  state :cancelled

  transition from: :pending,      to: [:checking_out, :cancelled]
  transition from: :checking_out, to: [:purchased, :cancelled]

  guard_transition(to: :checking_out) do |order|
    order.products_in_stock?
  end

  before_transition(from: :checking_out, to: :cancelled) do |order, transition|
    order.reallocate_stock
  end

  after_transition(to: :purchased) do |order, transition|
    MailerService.order_confirmation(order).deliver
  end
end

Instantiating a Statesmin::Machine

The Statesman::Machine instance initializer now takes a state option which sets the initial state of the state machine. If the state option is omitted, the initial: true state from the Machine definition is used. Passing an invalid state will yield a Statesmin::InvalidStateError.

# A valid state is set as the current_state
state_machine = OrderStateMachine.new(Order.first, state: :cancelled)
state_machine.current_state # => "cancelled"

# Invalid states raise an InvaliedStateError
state_machine = OrderStateMachine.new(Order.first, state: :whoops)
# => raise Statesmin::InvalidStateError

# No state option sets the state to the initial state
state_machine = OrderStateMachine.new(Order.first)
state_machine.current_state # => "pending"

Statesmin::Machine instance methods

All instance methods from statesman are available on statesmin with the exception of #history and #last_transition.

state_machine = OrderStateMachine.new(Order.first)
state_machine.current_state # => "pending"
state_machine.in_state?(:failed, :cancelled) # => true/false
state_machine.allowed_transitions # => ["checking_out", "cancelled"]
state_machine.can_transition_to?(:cancelled) # => true/false

The #transition_to and #transition_to! methods are updated. They now simply update the state machines internal current state to the new state when it is valid. transition_to! raises a Statesmin::TransitionFailedError when an invalid state is given. transition_to returns false.

state_machine = OrderStateMachine.new(Order.first, state: :pending)
state_machine.current_state # => "pending"

state_machine.transition_to!(:invalid_state)
# => raise Statesmin::TransitionFailedError

state_machine.transition_to(:invalid_state)
# => false
state_machine.current_state # => "pending"

state_machine.transition_to!(:checking_out) # => true
state_machine.current_state # => "checking_out"

Statesmin::Machine #transition_to! and #transition_to

The #transition_to and #transition_to! methods now both take a block argument as well. If a block is given, any error raised in the block body will halt the transition and not update the current state. transition_to! will always raise the error from the block body, while transition_to will return false if a Statesmin::TransitionFailedError is raised. transition_to will still raise all other errors.

#transition_to and #transition_to! will both return the value returned from the block when they are called without errors. The state machine's current state is updated to the new state immediately after the block has executed.

Finally, #transition_to and #transition_to! will only execute the given block if the state argument is a valid transition. Invalid state arguments will behave the same way as they do without blocks, either returning false or raising a Statesmin::TransitionFailedError respectively.

state_machine = OrderStateMachine.new(Order.first, state: :pending)
state_machine.current_state # => "pending"

state_machine.transition_to! :invalid_state do
  puts 'never evaluated due to the :invalid_state argument'
end
# => raise Statesmin::TransitionFailedError

state_machine.transition_to :checking_out do
  raise Statesmin::TransitionFailedError
end
# => false

state_machine.transition_to :checking_out do
  raise Order::InvalidAddress
end
# => raise Order::InvalidAddress
state_machine.current_state # => "pending"

state_machine.transition_to :checking_out do
  OrderLogEntry.create!(order_data)
end
# => <#OrderLogEntry>
state_machine.current_state # => "checking_out

The transition block is the basis of how Statesmin allows for custom transition behavior and distinguishes itself from Statesman. For small application or transition requirements, the transition block may be sufficient but in most cases defining a Transition class is recommended.

Defining a Transition class

You are free to set up a state machine and corresponding transition behavior however you like. The TransitionHelper module is included to help provide structure and reduce boilerplate code.

Create a new class which includes the Statesmin::TransitionHelper module. This module does the following for you:

  • Sets up a good outline for a Transaction (service) class
  • Delegates reader methods to an underlying state machine instance
  • Intercepts transition methods so they may be extend with specific behavior

Statesmin::TransitionHelper requires you to define two methods in your transition class:

  • state_machine - This method returns the instance of the Statesmin::Machine class to use in the class. The reader methods delegate to this state machine instance. You will most likely also need it in other methods.

  • transition - This method defines the custom portion of the transition logic for this application and object. Usually, you will trigger state persistence, Transition logging, and callback execution from this method. Multiple database updates are always recommended to be wrapped in a transaction.

Example

The following example does the following during a transition:

  • Builds and saves an OrderLog record to the OrderLog table
  • Persists the current state of the order in the Order table.
  • Executes any before, after, and after_commit callbacks for the specific transition
  • Commits all of these database updates atomically (everything or nothing)
  • Returns the newly created order log record.
class OrderTransitionService
  include Statesmin::TransitionHelper

  def initialize(order)
    @order = order
  end

  private

  def transition(next_state, data = {})
    order_log = build_order_log_entry(next_state, data)

    ::ActiveRecord::Base.transaction do
      state_machine.execute(:before, current_state, next_state, data)
      @order.update!(state: next_state)
      order_log.save!
      state_machine.execute(:after, current_state, next_state, data)
    end
    state_machine.execute(:after_commit, current_state, next_state, data)

    order_log
  end
  
  def state_machine
    @state_machine ||= OrderStateMachine.new(@order, state: @order.state)
  end
  
  def build_order_log_entry(next_state, data)
    log_attributes = { from: current_state, to: next_state, data: data }
    @order.order_logs.build(log_attributes)
  end
end

An instance of OrderTransitionService now has the same methods as Statesmin::Machine.

order_transition = OrderTransitionService.new(Order.first)

# reader methods are delegated to `state_machine`
order_transition.current_state # => "pending"
order_transition.in_state?(:failed, :cancelled) # => true/false
order_transition.allowed_transitions # => ["checking_out", "cancelled"]
order_transition.can_transition_to?(:cancelled) # => true/false

# `transition_to` and `transition_to!` also execute the transition method
order_transition.transition_to(:invalid_state)
# => false
order_transition.current_state # => "pending"

order_transition.transition_to!(:checking_out)
# => <#OrderLogEntry>
order_transition.current_state # => "checking_out"

Flexibility

The above example defines behavior similar to Statesman. Some examples of what else can be done with an open Transition class.

  • Have multiple state machines for the same object by adding a condition in the states_machine method.
  • Have multiple types a transitions for the same object by defining multiple Transition classes with the same instantiating object.
  • Have different Transition logs/tables for different objects.
  • Turn parts of a transition on and off based off of an initializer argument

The following is an adapted version of the original Statesman README.


Statesmin

A statesmanlike state machine library for Ruby 2.0.0 and up.

Statesmin is an opinionated state machine library 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.

As such, the design of statesman is a little different from other state machine libraries:

  • State behaviour is defined in a separate, "state machine" class, rather than added directly onto a model. State machines are then instantiated with the model to which they should apply.
  • State transitions are also modelled as a class, which can optionally be persisted to the database for a full audit history. This audit history can include JSON metadata set during a transition.
  • Database indices are used to offer database-level transaction duplication protection.
  • Free to define your own transition logic for your application!

TL;DR Usage

#######################
# State Machine Class #
#######################
class OrderStateMachine
  include Statesmin::Machine

  state :pending, initial: true
  state :checking_out
  state :purchased
  state :shipped
  state :cancelled
  state :failed
  state :refunded

  transition from: :pending,      to: [:checking_out, :cancelled]
  transition from: :checking_out, to: [:purchased, :cancelled]
  transition from: :purchased,    to: [:shipped, :failed]
  transition from: :shipped,      to: :refunded

  guard_transition(to: :checking_out) do |order|
    order.products_in_stock?
  end

  before_transition(from: :checking_out, to: :cancelled) do |order, transition|
    order.reallocate_stock
  end

  before_transition(to: :purchased) do |order, transition|
    PaymentService.new(order).submit
  end

  after_transition(to: :purchased) do |order, transition|
    MailerService.order_confirmation(order).deliver
  end
end

##############
# Your Model #
##############
class Order < ActiveRecord::Base
  include Statesmin::Adapters::ActiveRecordQueries

  has_many :order_transitions, autosave: false

  def state_machine
    @state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition)
  end

  def self.transition_class
    OrderTransition
  end
  private_class_method :transition_class

  def self.initial_state
    :pending
  end
  private_class_method :initial_state
end

####################
# Transition Model #
####################
class OrderTransition < ActiveRecord::Base
  include Statesmin::Adapters::ActiveRecordTransition

  belongs_to :order, inverse_of: :order_transitions
end

########################
# Example method calls #
########################
Order.first.state_machine.current_state # => "pending"
Order.first.state_machine.allowed_transitions # => ["checking_out", "cancelled"]
Order.first.state_machine.can_transition_to?(:cancelled) # => true/false
Order.first.state_machine.transition_to(:cancelled, optional: :metadata) # => true/false
Order.first.state_machine.transition_to!(:cancelled) # => true/exception

Order.in_state(:cancelled) # => [#<Order id: "123">]
Order.not_in_state(:checking_out) # => [#<Order id: "123">]

Class methods

Machine.state

Machine.state(:some_state, initial: true)
Machine.state(:another_state)

Define a new state and optionally mark as the initial state.

Machine.transition

Machine.transition(from: :some_state, to: :another_state)

Define a transition rule. Both method parameters are required, to can also be an array of states (.transition(from: :some_state, to: [:another_state, :some_other_state])).

Machine.guard_transition

Machine.guard_transition(from: :some_state, to: :another_state) do |object|
  object.some_boolean?
end

Define a guard. to and from parameters are optional, a nil parameter means guard all transitions. The passed block should evaluate to a boolean and must be idempotent as it could be called many times.

Machine.before_transition

Machine.before_transition(from: :some_state, to: :another_state) do |object|
  object.side_effect
end

Define a callback to run before a transition. to and from parameters are optional, a nil parameter means run before all transitions. This callback can have side-effects as it will only be run once immediately before the transition.

Machine.after_transition

Machine.after_transition(from: :some_state, to: :another_state) do |object, transition|
  object.side_effect
end

Define a callback to run after a successful transition. to and from parameters are optional, a nil parameter means run after all transitions. The model object and transition object are passed as arguments to the callback. This callback can have side-effects as it will only be run once immediately after the transition.

If you specify after_commit: true, the callback will be executed once the transition has been committed to the database.

Machine.new

my_machine = Machine.new(my_model)

Initialize a new state machine instance. my_model is required.

Machine.retry_conflicts

Machine.retry_conflicts { instance.transition_to(:new_state) }

Automatically retry the given block if a TransitionConflictError is raised. If you know you want to retry a transition if it fails due to a race condition call it from within this block. Takes an (optional) argument for the maximum number of retry attempts (defaults to 1).

Instance methods

Machine#current_state

Returns the current state based on existing transition objects.

Machine#in_state?(:state_1, :state_2, ...)

Returns true if the machine is in any of the given states.

Machine#allowed_transitions

Returns an array of states you can transition_to from current state.

Machine#can_transition_to?(:state)

Returns true if the current state can transition to the passed state and all applicable guards pass.

Machine#transition_to!(:state)

Transition to the passed state, returning true on success. Raises Statesmin::GuardFailedError or Statesmin::TransitionFailedError on failure.

Machine#transition_to(:state)

Transition to the passed state, returning true on success. Swallows all Statesmin exceptions and returns false on failure. (NB. if your guard or callback code throws an exception, it will not be caught.)

Frequently Asked Questions

Storing the state on the model object

If you wish to store the model state on the model directly, you can keep it up to date using an after_transition hook:

after_transition do |model, transition|
  model.state = transition.to_state
  model.save!
end

You could also use a calculated column or view in your database.

Accessing metadata from the last transition

Given a field foo that was stored in the metadata, you can access it like so:

model_instance.last_transition.metadata["foo"]

Events

Used to using a state machine with "events"? Support for events is provided by the statesman-events gem. Once that's included in your Gemfile you can include event functionality in your state machine as follows:

class OrderStateMachine
  include Statesmin::Machine
  include Statesmin::Events

  ...
end

Testing Statesmin Implementations

This answer was abstracted from this issue.

At GoCardless we focus on testing that:

  • guards correctly prevent / allow transitions
  • callbacks execute when expected and perform the expected actions

Testing Guards

Guards can be tested by asserting that transition_to! does or does not raise a Statesmin::GuardFailedError:

describe "guards" do
  it "cannot transition from state foo to state bar" do
    expect { some_model.transition_to!(:bar) }.to raise_error(Statesmin::GuardFailedError)
  end

  it "can transition from state foo to state baz" do
    expect { some_model.transition_to!(:baz) }.to_not raise_error
  end
end

Testing Callbacks

Callbacks are tested by asserting that the action they perform occurs:

describe "some callback" do
  it "adds one to the count property on the model" do
    expect { some_model.transition_to!(:some_state) }.
      to change { some_model.reload.count }.
      by(1)
  end
end

GoCardless ♥ open source. If you do too, come join us.

Packages

No packages published

Languages

  • Ruby 100.0%