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:
- 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
- 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
- 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!