State machines power critical workflows in Rails applicationsโorder processing, user onboarding, content moderation, subscription lifecycles. While gems like AASM and StateMachines offer rich features, Rails 8 provides everything needed to build clean, maintainable state machines with zero dependencies.
The Problem with Ad-Hoc Status Fields
Many Rails applications start with a simple status column and scattered conditionals throughout the codebase. This approach quickly becomes unmaintainable:
# app/models/order.rb (the messy approach)
class Order < ApplicationRecord
def can_ship?
status == 'paid' && !shipped_at.present?
end
def ship!
return false unless can_ship?
update(status: 'shipped', shipped_at: Time.current)
end
# Repeated across dozens of methods...
endThe issues compound: invalid transitions slip through, business logic scatters across controllers and models, and testing becomes a nightmare. A proper state machine centralizes transition rules and guards in one readable location.
Building the State Machine Foundation
Start with a concern that handles state definition, transition validation, and event methods. This pattern keeps the implementation reusable across models.
# app/models/concerns/state_machine.rb
module StateMachine
extend ActiveSupport::Concern
class InvalidTransition < StandardError; end
class_methods do
def state_machine(column: :state, &block)
config = Configuration.new
config.instance_eval(&block)
# Store configuration for this model
class_attribute :state_machine_config, default: {
column: column,
states: config.states,
events: config.events,
initial: config.initial_state
}
# Define scopes for each state
config.states.each do |state|
scope state, -> { where(column => state.to_s) }
end
# Define event methods
config.events.each do |event_name, event_config|
define_event_methods(event_name, event_config, column)
end
# Set initial state before validation on create
before_validation :set_initial_state, on: :create
# Validate state is known
validates column, inclusion: {
in: config.states.map(&:to_s),
message: "'%{value}' is not a valid state"
}
end
private
def define_event_methods(event_name, event_config, column)
# Predicate: can_publish?
define_method("can_#{event_name}?") do
current = send(column)&.to_sym
event_config[:from].include?(current) &&
evaluate_guard(event_config[:guard])
end
# Transition: publish (returns boolean)
define_method(event_name) do
return false unless send("can_#{event_name}?")
perform_transition(event_name, event_config, column)
end
# Transition: publish! (raises on failure)
define_method("#{event_name}!") do
unless send("can_#{event_name}?")
raise InvalidTransition,
"Cannot #{event_name} from '#{send(column)}'"
end
perform_transition(event_name, event_config, column) ||
raise(ActiveRecord::RecordInvalid, self)
end
end
end
private
def set_initial_state
column = self.class.state_machine_config[:column]
return if send(column).present?
send("#{column}=", self.class.state_machine_config[:initial].to_s)
end
def evaluate_guard(guard)
return true unless guard
guard.is_a?(Proc) ? instance_exec(&guard) : send(guard)
end
def perform_transition(event_name, event_config, column)
old_state = send(column)
send("#{column}=", event_config[:to].to_s)
transaction do
run_callbacks(event_config[:after], old_state) if event_config[:after]
save
end
end
def run_callbacks(callbacks, old_state)
Array(callbacks).each do |callback|
callback.is_a?(Proc) ? instance_exec(old_state, &callback) : send(callback)
end
end
# Configuration DSL class
class Configuration
attr_reader :states, :events, :initial_state
def initialize
@states = []
@events = {}
@initial_state = nil
end
def state(*names, initial: false)
@states.concat(names)
@initial_state = names.first if initial
end
def event(name, from:, to:, guard: nil, after: nil)
@events[name] = {
from: Array(from),
to: to,
guard: guard,
after: after
}
end
end
endImplementing a Real-World Order State Machine
With the foundation in place, defining state machines becomes declarative and readable. Here's a complete order workflow implementation:
# app/models/order.rb
class Order < ApplicationRecord
include StateMachine
belongs_to :user
has_many :order_items, dependent: :destroy
state_machine column: :status do
state :pending, initial: true
state :confirmed
state :paid
state :shipped
state :delivered
state :cancelled
state :refunded
event :confirm,
from: :pending,
to: :confirmed,
guard: :has_items?,
after: :send_confirmation_email
event :pay,
from: :confirmed,
to: :paid,
after: ->(old_state) { record_payment_at }
event :ship,
from: :paid,
to: :shipped,
guard: :has_tracking_number?,
after: %i[send_shipping_email notify_warehouse]
event :deliver,
from: :shipped,
to: :delivered,
after: :complete_fulfillment
event :cancel,
from: %i[pending confirmed],
to: :cancelled,
after: :release_inventory
event :refund,
from: %i[paid shipped delivered],
to: :refunded,
guard: -> { refundable_period? },
after: :process_refund
end
private
def has_items?
order_items.any?
end
def has_tracking_number?
tracking_number.present?
end
def refundable_period?
created_at > 30.days.ago
end
def record_payment_at
update_column(:paid_at, Time.current)
end
def send_confirmation_email
OrderMailer.confirmed(self).deliver_later
end
def send_shipping_email
OrderMailer.shipped(self).deliver_later
end
def notify_warehouse
WarehouseNotificationJob.perform_later(id)
end
def complete_fulfillment
update_column(:delivered_at, Time.current)
end
def release_inventory
order_items.each(&:release_stock!)
end
def process_refund
RefundJob.perform_later(id)
end
endUsing the State Machine in Controllers
The state machine integrates naturally with Rails controllers. Transitions can drive Turbo Stream responses for real-time UI updates:
# app/controllers/admin/orders_controller.rb
module Admin
class OrdersController < ApplicationController
before_action :set_order, only: %i[show ship cancel]
def ship
if @order.ship!
respond_to do |format|
format.html { redirect_to admin_order_path(@order), notice: "Order shipped" }
format.turbo_stream
end
else
redirect_to admin_order_path(@order),
alert: "Cannot ship order: #{@order.errors.full_messages.join(', ')}"
end
rescue StateMachine::InvalidTransition => e
redirect_to admin_order_path(@order), alert: e.message
end
def cancel
@order.cancel!
respond_to do |format|
format.html { redirect_to admin_orders_path, notice: "Order cancelled" }
format.turbo_stream
end
rescue StateMachine::InvalidTransition => e
redirect_to admin_order_path(@order), alert: e.message
end
private
def set_order
@order = Order.find(params[:id])
end
end
endTesting State Transitions
State machines benefit enormously from comprehensive tests. Focus on valid transitions, invalid transition rejections, and guard conditions:
# spec/models/order_spec.rb
RSpec.describe Order do
describe "state machine" do
let(:order) { create(:order, :with_items) }
describe "#confirm" do
context "when order has items" do
it "transitions from pending to confirmed" do
expect { order.confirm! }
.to change(order, :status).from("pending").to("confirmed")
end
it "sends confirmation email" do
expect { order.confirm! }
.to have_enqueued_mail(OrderMailer, :confirmed)
end
end
context "when order has no items" do
let(:order) { create(:order) }
it "prevents transition" do
expect(order.can_confirm?).to be false
expect(order.confirm).to be false
end
end
end
describe "#cancel" do
it "allows cancellation from pending" do
expect(order.can_cancel?).to be true
end
it "prevents cancellation from shipped" do
order.update_column(:status, "shipped")
expect(order.can_cancel?).to be false
expect { order.cancel! }.to raise_error(StateMachine::InvalidTransition)
end
end
end
endCommon Pitfalls and Solutions
Bypassing the state machine: Direct column updates via update_column skip validations and callbacks. Reserve these for migrations or data fixes, never business logic.
Missing database indexes: State columns used in queries need indexes. Add them in migrations: add_index :orders, :status.
Overloaded callbacks: Keep after callbacks focused. Heavy operations belong in background jobs, not synchronous transitions.
Summary
Building state machines without gems provides full control over transition logic while keeping dependencies minimal. The pattern shown here handles guards, callbacks, and multiple source states cleanly. For applications with straightforward workflows, this approach offers simplicity and transparency. Complex requirements involving parallel states or nested machines may still warrant dedicated gems, but most Rails applications can manage state effectively with pure Ruby and Active Record.