# Statesman keeps transitions as rows, which is the whole argument

> A Ruby state machine where the state machine lives in its own class, transitions are their own persisted model, and database indices provide duplicate-transition protection. The audit trail is not an add-on feature; it is the storage design.

**gocardless/statesman** — A statesmanlike state machine library.

- Repository: https://github.com/gocardless/statesman
- Website: https://gocardless.com/blog/statesman/
- Stars: 1,913 · Forks: 158
- Language: Ruby
- License: MIT
- Published: 2026-10-16 · Updated: 2026-10-16 · Language: en
- Canonical page: https://hysenlabs.com/projects/gocardless-statesman

## Three design choices that separate it from other state machines

The README names three ways Statesman's design differs from other state machine libraries, and they are worth reading in order because each one depends on the last.

First, state behaviour is defined in a separate state machine class rather than added onto a model. The machine is instantiated with the model it applies to. So the rules about what an order may do live in OrderStateMachine, and the Order class stays a data object.

Second, transitions are modelled as a class of their own, which can optionally be persisted for a full audit history, and that history can include JSON metadata set during the transition.

Third, database indices are used to offer duplicate-transition protection at the database level.

That third point is the reason the first two exist. Once transitions are rows, the database can refuse a second identical row through a unique index, and the protection no longer depends on application code running inside a transaction. The README describes the library as opinionated and says it is designed to provide an audit trail and data integrity, which is an accurate summary of what the storage model buys.

## Writing a machine is a declaration, not a diagram

A machine is a class that includes Statesman::Machine and then declares states and the transitions between them. The order example is the README's own, and it declares six states with one marked initial.

```ruby
class OrderStateMachine
  include Statesman::Machine

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

Transitions are declared next, and a single transition may list several target states.

```ruby
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
```

Read as a graph, the shape is deliberate: cancellation is reachable from two early states, failure only from purchased, and refund only from shipped. A state with no outgoing transition is terminal, and that is expressed by leaving it out rather than by a flag.

## Guards and callbacks are matched by state, not by method name

Three hook types sit around a transition, each selected by the states involved rather than by naming a method.

guard_transition runs before the transition is allowed and can refuse it. The example checks stock before moving to checking_out.

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

The important detail is that a guard is specified with to: and can therefore match every transition into that state. You do not enumerate the source states.

before_transition can be narrowed to a specific pair, as in from: :checking_out, to: :cancelled, which is how the order reallocates stock on that one path. The same hook with only to: :purchased matches any transition into purchased, which is where the example submits payment.

after_transition(to: :purchased) is used for work that must not roll the transition back, in the example sending an order confirmation mail.

The block signature is (order, transition) for before and after, and just (order) for a guard. The transition object being passed into the hooks is what lets a callback read the metadata attached to the change it is reacting to.

## Linking the machine to the model and the transitions to a table

The model includes a bracket-style mixin carrying configuration rather than a plain include.

```ruby
class Order < ActiveRecord::Base
  has_many :order_transitions, autosave: false

  include Statesman::Adapters::ActiveRecordQueries[
    transition_class: OrderTransition,
    initial_state: :pending
  ]

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

The adapter mixin is what adds the query scope, and the transition model is a plain ActiveRecord class with two pieces of Statesman behaviour.

```ruby
class OrderTransition < ActiveRecord::Base
  include Statesman::Adapters::ActiveRecordTransition

  validates :to_state, inclusion: { in: OrderStateMachine.states }

  belongs_to :order, inverse_of: :order_transitions
end
```

The validation on to_state is worth noting: the database is constrained to states the machine actually declares, so a hand-written or migrated bad row fails rather than silently existing.

Once that is in place, the machine exposes current_state, allowed_transitions, can_transition_to?, transition_to, transition_to!, last_transition and last_transition_to, and the model gets scopes in_state and not_in_state.

## Two transition methods, and the difference is an exception

transition_to returns a boolean and transition_to! raises. That is the whole difference, and it maps to whether the caller wants to branch or to stop.

```ruby
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
```

Note the optional: :metadata argument on the non-bang form. Metadata is passed at the point of the transition rather than configured globally, which is what lets a single machine carry different reasons for different changes.

The read side is equally direct. last_transition gives you the transition model or nil, and last_transition_to(:pending) filters that to a specific target state, returning nil when the last transition did not go there.

And because the adapter is included, the queries work on the class.

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

That last pair is the payoff of persisting transitions. current_state is derived from the transition history rather than stored as a column, so there is no possibility of the column and the history disagreeing.

## Templates let subclasses remove transitions, not just states

A module can define the states and transitions of a machine so several classes share them, and each class then adjusts. The README's example defines a three-state cycle and then shows three ways of diverging from it.

The Circular class takes the template unchanged. Linear extends it and removes one transition with remove_transitions from: :c, to: :a, turning a cycle into a line. Shorter removes a state entirely with remove_state :c.

The pattern is define_states and define_transitions rather than the state and transition macros directly, because the template defines methods that the including class then calls. Both the module's methods and the Statesman macros are available once it extends Template.

This is the mechanism for a family of similar but not identical processes, such as several approval workflows that share four states and differ in whether they loop. It works because the machine is a class rather than a hash of configuration.

## Storage is opt-in, and turning it on is a schema change

By default Statesman keeps transition history in memory only. Persistence requires configuring a storage adapter, and for ActiveRecord that is done in an initializer.

```ruby
Statesman.configure do
  storage_adapter(Statesman::Adapters::ActiveRecord)
end
```

Then you generate the transition model with a Rails generator, which creates the migration and the class.

```bash
rails g statesman:active_record_transition Order OrderTransition
```

Two details there are easy to miss. The transition class must include Statesman::Adapters::ActiveRecordTransition, which is a separate concern from the adapter on the model. And if you decide not to have the default updated_at column, you must set updated_timestamp_column on the transition class to another column name such as :updated_on, or to nil.

Metadata is stored as JSON by default using serialize, and the README also describes using a PostgreSQL JSON column instead. The association from the parent model needs a class_name and autosave: false, and in the fuller example the machine is constructed with both transition_class and association_name.

## Release pace and the compatibility policy

The repository is not archived and the last push was on 2026-10-14, the same day as the v13.4.0 tag. Before that, v13.3.0 on 2026-08-04 and v13.2.0 on 2026-07-30.

That is three minor releases in about eleven weeks on a 13.x line, which is a fast cadence for a library that people put in the core of a domain model. The README carries a SemVer compatibility badge comparing 11.0.0 with 12.0.0, which indicates the project treats version compatibility as something worth surfacing.

Runtime support is delegated to a document rather than to the README: docs/COMPATIBILITY.md holds the policy for compatibility with Ruby and Rails versions. Read that before pinning, since the repository also carries a .ruby-version file and a .tool-versions file and the supported combinations will differ from what your CI image happens to have.

The licence is MIT, in LICENSE.txt. For a library embedded in an application's domain layer, that means no obligation beyond the notice.

The surrounding tooling is conventional: RSpec with a .rspec file and a spec/ directory, RuboCop with a .rubocop_todo.yml alongside the config, a Guardfile for watch mode, a Rakefile, a CHANGELOG.md, and CircleCI as the build system.

## Conclusion

Use Statesman when the sequence of states is itself a business record, such as an order moving through checkout, purchase, shipping, failure and refund, because every transition becomes a row you can query, timestamp and attach metadata to. Do not use it for a UI toggle such as expanded or collapsed, where the audit history has no value and the extra model and association cost more than the states save. Verify first which Ruby and Rails versions your build supports against docs/COMPATIBILITY.md, and decide early whether you need the ActiveRecord adapter at all, since the default storage is in memory only and the generated transition table is a schema change.

## FAQ

### How do I persist Statesman transitions to a database?

Configure the ActiveRecord storage adapter in an initializer with Statesman.configure and storage_adapter(Statesman::Adapters::ActiveRecord), then generate the transition model with rails g statesman:active_record_transition Order OrderTransition and include Statesman::Adapters::ActiveRecordTransition in it.

### What is the difference between transition_to and transition_to!?

transition_to returns a boolean, and transition_to! raises if the transition is not allowed. Both accept metadata, so you can attach context to the change at the point you make it.

### How do I prevent a state transition in Statesman?

Use guard_transition, which is matched with to: and therefore applies to every transition into that state. The block receives the model and returns a truthy value to allow the transition, as in guard_transition(to: :checking_out) do |order| order.products_in_stock? end.

### Can I query models by their current state?

Yes. Including Statesman::Adapters::ActiveRecordQueries with the transition class and initial state adds scopes, so Order.in_state(:cancelled) and Order.not_in_state(:checking_out) return matching records.

## Sources

- [gocardless/statesman on GitHub](https://github.com/gocardless/statesman)
- [License: MIT](https://github.com/gocardless/statesman/blob/master/LICENSE)
- [Project website](https://gocardless.com/blog/statesman/)
- [README](https://github.com/gocardless/statesman/blob/master/README.md)
- [Releases](https://github.com/gocardless/statesman/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/gocardless-statesman
