My First State Machine in Rails
Three months into building OutfitMaker, my outfit suggestion code looked like this:
def generate_suggestion(user)
return if user.generating?
return if user.last_suggestion_at && user.last_suggestion_at > 1.hour.ago
user.update(generating: true)
begin
items = user.clothing_items.where(season: current_season)
return user.update(generating: false) if items.empty?
prompt = build_prompt(user, items)
response = GeminiClient.call(prompt)
if response.success?
suggestion = user.suggestions.create!(body: response.body, status: 'completed')
user.update(generating: false, last_suggestion_at: Time.current)
else
user.update(generating: false, last_error: response.error)
end
rescue => e
user.update(generating: false, last_error: e.message)
end
end
Boolean flags. Rescue blocks. Early returns scattered like landmines. The method worked, but every time I needed to add a feature (retry logic, timeout handling, rate limiting), I had to trace through the entire control flow to figure out which state the suggestion was in.
The turning point
I wanted to add a simple feature: show users a “generating…” indicator while their outfit was being created. Sounds trivial. But the generating boolean wasn’t enough. I needed to distinguish between “waiting in queue,” “actively calling the AI,” and “processing the response.” Three states, one boolean. The boolean had to go.
Enter AASM
I chose AASM (Acts As State Machine) because it’s the most established state machine gem in the Rails ecosystem. Alternatives exist (statesman, state_machines-activerecord) but AASM had the clearest documentation and worked well with ActiveRecord out of the box.
Here’s what the suggestion model became:
class Suggestion < ApplicationRecord
include AASM
belongs_to :user
aasm column: :status do
state :pending, initial: true
state :generating
state :completed
state :failed
event :start_generation do
transitions from: :pending, to: :generating
after do
GenerateSuggestionJob.perform_later(self)
end
end
event :complete do
transitions from: :generating, to: :completed
after do
update(completed_at: Time.current)
broadcast_replace_to(user, target: "suggestion_#{id}")
end
end
event :fail do
transitions from: :generating, to: :failed
after do |error_message|
update(error: error_message)
broadcast_replace_to(user, target: "suggestion_#{id}")
end
end
end
end
The state diagram was now explicit. pending → generating → completed or pending → generating → failed. No other paths possible. No boolean that could get stuck in the wrong state.
What this gave me immediately
Visual clarity. I drew the state diagram on a Post-it note. Four circles, four arrows. Anyone looking at this model can understand the lifecycle in ten seconds.
Guard rails. Try calling suggestion.complete! when it’s in pending state? AASM raises InvalidTransition. The model literally cannot enter an illegal state. No more “generating is true but last_error is also set” contradictions.
Callbacks on transitions. The after blocks on each event replaced scattered callback logic. When a suggestion completes, it broadcasts via Turbo Stream. When it fails, it records the error. The logic lives where the state change happens, not in some distant service object.
Database-backed. The status column is a string. I can query Suggestion.where(status: :generating) to find stuck suggestions. I can add a cron job that fails any suggestion stuck in generating for more than 5 minutes, and timeout handling became a three-line method.
The gotchas
Gotcha 1: Column naming
AASM defaults to a column called aasm_state. I wanted status. Easy enough to configure with aasm column: :status, but I forgot this in the migration and had to add a rename migration. Small thing, but read the docs before generating your migration.
Gotcha 2: Validation timing
AASM transitions happen before save by default. If you have after callbacks that update the record, you get double saves. I switched to aasm column: :status, whiny_persistence: true which wraps transitions in a transaction and raises if the save fails.
Gotcha 3: Testing transitions
You can’t just set suggestion.status = 'completed' in tests. AASM will complain. You need to walk through the states:
test "completing a suggestion records the timestamp" do
suggestion = create(:suggestion, status: :pending)
suggestion.start_generation!
suggestion.complete!
assert_not_nil suggestion.completed_at
end
This felt verbose at first, but it’s actually better: your tests prove the state machine works correctly, not just that the final state looks right.
Gotcha 4: Background jobs and race conditions
The after callback on start_generation enqueues a background job. If the job picks up before the transaction commits, it might find the suggestion still in pending state. Solution: use after_commit or enqueue with a slight delay. I went with after_commit:
event :start_generation do
transitions from: :pending, to: :generating
after_commit do
GenerateSuggestionJob.perform_later(self)
end
end
The background job simplified
With the state machine handling transitions, the job itself became almost trivial:
class GenerateSuggestionJob < ApplicationJob
def perform(suggestion)
return unless suggestion.generating?
response = GeminiClient.generate_outfit(suggestion.user)
if response.success?
suggestion.update!(body: response.body)
suggestion.complete!
else
suggestion.fail!(response.error)
end
rescue => e
suggestion.fail!(e.message)
end
end
The guard clause return unless suggestion.generating? handles duplicate job execution. The rescue triggers the fail transition. No boolean flags to manage. No manual state cleanup.
When state machines are overkill
Not everything needs a state machine. My ClothingItem model has a processing boolean for background removal; it’s either processing or it’s not. Two states, one transition, no complex lifecycle. A boolean is fine there.
The signal that you need a state machine:
- More than two states
- Transitions that should only happen in certain orders
- Business logic that depends on “where” an object is in its lifecycle
- Bugs caused by objects getting stuck in impossible states
The refactor ripple
Adding the state machine to Suggestion made me rethink how I was handling other flows. User onboarding now has states: registered → profile_created → wardrobe_started → active. The subscription flow uses states too. Not everything needs AASM; sometimes a simple enum with validation is enough, but thinking in states instead of booleans made the entire codebase more predictable.
The pattern costs maybe 30 minutes of setup per model. In return, you get code that’s easier to debug, test, extend, and explain. For a solo developer who has to context-switch constantly between features, that clarity is worth more than cleverness.