Rails 8 State Machines Without Gems

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...
end

The 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
end

Implementing 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
end

Using 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
end

Testing 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
end

Common 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.

10 claps
โ† Back to Blog