How can the AASM state machine be replaced with built-in Ruby on Rails methods?

If you've been working with Rails applications for a long time, you've probably encountered the AASM gem (formerly acts_as_state_machine). It's a popular solution for managing object states, but do you really need all of its features? In this article, I'll show you how to replace AASM with built-in mechanisms in Rails 7.1+, resulting in simpler and more efficient code.

Why consider giving up AASM?

AASM is a powerful tool, but with power comes complexity. The main issues I encounter when using it are:

  • Excessive complexity - the gem offers a lot of features you probably don't need. More features mean more temptation to use them, which leads to complicated code.
  • IDE and LSP issues - Code analysis tools have difficulty recognizing methods generated by the AASM DSL. Instead of auto-complete, you have to use “grep” and read the source code.
  • Hidden code generation - AASM automatically creates constants for states (e.g., User::STATUS_ACTIVE), which can be confusing when they are not explicitly defined in the class.
  • Coupling to an external library - the more you use AASM in your application, the harder it will be to give it up or update Rails later.
  • Promotion of anti-patterns - AASM encourages the use of callbacks, which can significantly complicate business logic.

Rails 7.1 to the rescue

The good news is that Rails 7.1 introduced all the features needed to replace AASM. The key is the extended functionality of enum, which now offers:

  • Scopes for each state
  • Query methods (predicate methods)
  • Methods to transition between states
  • Validations
  • Better integration with forms

Example: project management system

# Before: with AASM
class Project
  include AASM

  DRAFT = :draft
  CONFIRMED = :confirmed
  IN_PROGRESS = :in_progress
  COMPLETED = :completed
  ON_HOLD = :on_hold
  CANCELLED = :cancelled

  aasm column: :status do
    state :draft, initial: true
    state :confirmed, :in_progress, :completed, :on_hold, :cancelled

    event :confirm do
      transitions from: :draft, to: :confirmed
      after do
        ProjectMailer.confirmation_email(self).deliver_later
      end
    end

    event :start_work do
      transitions from: :confirmed, to: :in_progress, guard: :team_assigned?
    end

    event :complete do
      transitions from: :in_progress, to: :completed
      after do
        calculate_final_metrics
        ProjectMailer.completion_email(self).deliver_later
      end
    end

    event :put_on_hold do
      transitions from: :in_progress, to: :on_hold
    end

    event :cancel do
      transitions from: [:draft, :confirmed, :on_hold], to: :cancelled
      after do
        notify_stakeholders if stakeholders.any?
      end
    end
  end

  private

  def team_assigned?
    true
    # logic for checking team assignment
  end

  def calculate_final_metrics
    # calculation of final metrics
  end
end

Implementation with Rails enum

Now let's see how the same can be achieved using built-in Rails mechanisms:

# After: with Rails enum
class Project < ApplicationRecord
  enum :status, {
    draft: 0,
    confirmed: 1,
    in_progress: 2,
    completed: 3,
    on_hold: 4,
    cancelled: 5
  }, default: :draft, validate: true

  # Scopes are automatically generated by enum
  # Project.draft, Project.confirmed, etc.

  # State transition methods
  def confirm!
    return false unless can_confirm?

    transaction do
      confirmed!
      UpdateProjectStatusService.new(self, :confirmed).call
    end
  end

  def start_work!
    return false unless can_start_work?

    transaction do
      in_progress!
      UpdateProjectStatusService.new(self, :in_progress).call
    end
  end

  def complete!
    return false unless can_complete?

    transaction do
      completed!
      UpdateProjectStatusService.new(self, :completed).call
    end
  end

  def put_on_hold!
    return false unless can_put_on_hold?

    on_hold!
  end

  def cancel!
    return false unless can_cancel?

    transaction do
      cancelled!
      UpdateProjectStatusService.new(self, :cancelled).call
    end
  end

  # Methods checking transition possibility
  def can_confirm?
    draft?
  end

  def can_start_work?
    confirmed? && team_assigned?
  end

  def can_complete?
    in_progress?
  end

  def can_put_on_hold?
    in_progress?
  end

  def can_cancel?
    draft? || confirmed? || on_hold?
  end

  private

  def team_assigned?
    project_members.where(role: 'developer').exists?
  end
end

Extracting logic to services

One of the practices is to extract business logic to separate services/or concerns:

# app/services/update_project_status_service.rb
class UpdateProjectStatusService
  def initialize(project, new_status)
    @project = project
    @new_status = new_status
  end

  def call
    case new_status
    when :confirmed
      handle_confirmation
    when :in_progress
      handle_start_work
    when :completed
      handle_completion
    when :cancelled
      handle_cancellation
    end
  end

  private

  attr_reader :project, :new_status

  def handle_confirmation
    send_confirmation_email
    log_status_change
    notify_team_members
  end

  def handle_start_work
    create_initial_tasks
    setup_project_tracking
    notify_stakeholders
  end

  def handle_completion
    calculate_final_metrics
    send_completion_email
    archive_project_resources
  end

  def handle_cancellation
    notify_stakeholders
    cleanup_resources
    log_cancellation_reason
  end

  def send_confirmation_email
    ProjectMailer.confirmation_email(project).deliver_later
  end

  def send_completion_email
    ProjectMailer.completion_email(project).deliver_later
  end

  def log_status_change
    Rails.logger.info "Project #{project.id} status changed to #{new_status} at #{Time.current}"
  end

  def notify_team_members
    project.project_members.each do |member|
      ProjectNotificationJob.perform_later(project.id, member.user_id, new_status)
    end
  end

  def calculate_final_metrics
    project.update!(
      completion_date: Time.current,
      final_budget: project.tasks.sum(:actual_cost),
      duration_days: (Time.current.to_date - project.start_date).to_i
    )
  end

  def notify_stakeholders
    project.stakeholders.each do |stakeholder|
      StakeholderNotificationJob.perform_later(project.id, stakeholder.id, new_status)
    end
  end
end

Validations and constraints

Rails enum allows you to easily add validations:

class Project < ApplicationRecord
  enum :status, {
    draft: 0,
    confirmed: 1,
    in_progress: 2,
    completed: 3,
    on_hold: 4,
    cancelled: 5
  }, default: :draft, validate: true

  # Additional validations
  validates :status, inclusion: { 
    in: statuses.keys,
    message: "must be a valid status" 
  }

  # Conditional validations
  validates :start_date, presence: true, if: :in_progress?
  validates :completion_date, presence: true, if: :completed?
  validates :budget, presence: true, numericality: { greater_than: 0 }, if: :confirmed?

  # Business logic validation
  validate :cannot_start_without_team
  validate :cannot_cancel_if_completed

  private

  def cannot_start_without_team
    if in_progress? && !team_assigned?
      errors.add(:status, "cannot be in progress without assigned team members")
    end
  end

  def cannot_cancel_if_completed
    if status_changed? && cancelled? && status_was == 'completed'
      errors.add(:status, "cannot cancel completed project")
    end
  end
end

Forms and selects

Rails enum integrates well with forms:

<!-- app/views/admin/projects/_form.html.erb -->
<%= form_with model: [:admin, @project] do |form| %>
  <div class="field">
    <%= form.label :status, "Project Status" %>
    <%= form.select :status, 
        options_from_collection_for_select(
          Project.statuses.map { |key, value| [key.humanize, key] }, 
          :second, :first, @project.status
        ),
        { prompt: "Select status" },
        { class: "form-select" } %>
  </div>

  <!-- Alternatively, using Rails helper -->
  <%= form.select :status, Project.statuses.keys.map { |status| 
    [t("project.statuses.#{status}"), status] 
  }, {}, { class: "form-select" } %>
<% end %>

Translation and internationalization

Rails enum works perfectly with i18n:

# config/locales/en.yml
en:
  activerecord:
    attributes:
      project:
        status: "Status"
    enums:
      project:
        status:
          draft: "Draft"
          confirmed: "Confirmed"
          in_progress: "In Progress"
          completed: "Completed"
          on_hold: "On Hold"
          cancelled: "Cancelled"
# In the model you can use:
def status_name
  I18n.t("activerecord.enums.project.status.#{status}")
end

# In views:
<%= @project.status.humanize %>
# or
<%= t("activerecord.enums.project.status.#{@project.status}") %>

Unit tests

Testing becomes simpler and more readable:

# spec/models/project_spec.rb
RSpec.describe Project, type: :model do
  describe "status transitions" do
    let(:project) { create(:project, :draft) }

    describe "#confirm!" do
      it "changes status from draft to confirmed" do
        expect { project.confirm! }.to change(project, :status)
          .from("draft").to("confirmed")
      end

      it "sends confirmation email" do
        expect(ProjectMailer).to receive(:confirmation_email)
          .with(project).and_return(double(deliver_later: true))

        project.confirm!
      end

      context "when project is not draft" do
        let(:project) { create(:project, :confirmed) }

        it "returns false and doesn't change status" do
          expect(project.confirm!).to be false
          expect(project.reload.status).to eq "confirmed"
        end
      end
    end

    describe "status scopes" do
      let!(:draft_project) { create(:project, :draft) }
      let!(:confirmed_project) { create(:project, :confirmed) }
      let!(:completed_project) { create(:project, :completed) }

      it "filters projects by status" do
        expect(Project.draft).to contain_exactly(draft_project)
        expect(Project.confirmed).to contain_exactly(confirmed_project)
        expect(Project.completed).to contain_exactly(completed_project)
      end
    end
  end

  describe "validations" do
    it "validates status inclusion" do
      project = build(:project, status: "invalid_status")
      expect(project).not_to be_valid
      expect(project.errors[:status]).to include("must be a valid status")
    end

    it "requires start date for in progress projects" do
      project = build(:project, :in_progress, start_date: nil)
      expect(project).not_to be_valid
      expect(project.errors[:start_date]).to include("can't be blank")
    end
  end
end

Performance and optimization

Rails enum offers several performance advantages:

  1. Indexing - states stored as integers are faster to index:
# db/migrate/xxx_add_index_to_projects_status.rb
class AddIndexToProjectsStatus < ActiveRecord::Migration[7.1]
  def change
    add_index :projects, :status
    add_index :projects, [:status, :created_at] # composite index
  end
end
  1. Database queries - enum generates efficient queries:
# These queries use integer indexes
Project.draft.count
Project.where(status: [:draft, :confirmed])

# Instead of slower string queries
Project.where(status: 'draft') # slower
  1. Memory - integers take up less space than strings in a database.

Advanced techniques with states

State machines with multiple enums

Sometimes you need several independent states:

class Project < ApplicationRecord
  enum :project_status, {
    draft: 0,
    confirmed: 1,
    in_progress: 2,
    completed: 3
  }

  enum :budget_status, {
    not_approved: 0,
    approved: 1,
    over_budget: 2
  }

  enum :delivery_status, {
    not_delivered: 0,
    delivered: 1,
    partially_delivered: 2
  }

  def can_complete?
    in_progress? && approved? && delivered?
  end
end

State history tracking

You can easily add state change history tracking:

class Project < ApplicationRecord
  enum :status, { draft: 0, confirmed: 1, completed: 2 }

  has_many :status_changes, dependent: :destroy

  after_update :track_status_change, if: :saved_change_to_status?

  private

  def track_status_change
    status_changes.create!(
      from_status: status_before_last_save,
      to_status: status,
      changed_at: Time.current,
      user: Current.user
    )
  end
end

# Model for tracking changes
class StatusChange < ApplicationRecord
  belongs_to :project
  belongs_to :user, optional: true

  validates :from_status, :to_status, :changed_at, presence: true
end

Summary

Replacing AASM with built-in Rails 7.1+ mechanisms brings many benefits:

  • Simpler code - less abstraction, more explicit logic
  • Better performance - enum uses integers instead of strings
  • Less coupling - no dependency on an external gem
  • Better IDE support - all methods are “real”, not generated by DSL
  • Easier testing - clear structure and predictable behavior
  • Flexibility - you can customize the logic exactly to your needs

Rails enum, combined with services and good OOP practices, gives you everything you need to manage states in your application. Instead of another layer of abstraction, you get readable, testable, and efficient code.

The next time you consider adding AASM to your project, think about whether you really need all of its features. Rails enum will probably suffice.

Happy enuming!