Rails State Machine: AASM Patterns for Orders, Subscriptions, and Workflows in Production
Rails state machine with AASM: production patterns for orders, subscriptions, and workflows. Guards, callbacks, optimistic locking, and the gotchas that bite.
The bug report read: “Customer paid twice.” It was a SaaS platform built on Rails, the kind of mid-stage startup that had grown faster than its codebase had matured. I dug in and found the root cause within an hour. The Order model had a status column that was being mutated from seven different controllers, two background jobs, and a Stripe webhook handler. Each one had its own opinion about which status transitions were legal. The webhook had quietly transitioned an already-paid order back to pending because the developer who wrote it had forgotten that paid orders should be terminal for that branch.
After nineteen years of Rails, the single most common architectural sin I see in growing codebases is status as a free-text column with no rules. A Rails state machine turns that mess into a single, auditable definition of how an object can move through its lifecycle. The gem I reach for is AASM, and this post is about using it well in production.
Why You Need a Rails State Machine
Every meaningful business object has a lifecycle. Orders move through pending, paid, shipped, delivered, refunded. Subscriptions move through trialing, active, past_due, canceled, expired. Documents move through draft, in_review, approved, published. Jobs move through queued, running, completed, failed.
The naive Rails implementation is a status string column with a few constants and a smattering of if order.status == "paid" checks scattered across the codebase. This works for a week and then rots. New developers add new statuses. Old transitions get bypassed. Webhooks set state directly without guards. Tests pass because nobody wrote integration tests for “what happens if Stripe sends a charge.succeeded after we already refunded?”
A Rails state machine gives you three things you cannot easily get any other way:
- A single declarative definition of legal states and transitions.
- Enforced guards that prevent illegal transitions at the model level, regardless of which controller or job initiated them.
- Hooks for side effects (sending email, enqueueing jobs, writing audit logs) tied to specific transitions, not scattered through callsites.
The Rails ecosystem has three credible options. I have used all three. Let me explain why I default to AASM.
AASM vs Statesman vs state_machines
state_machines is the spiritual successor to the original state_machine gem that Aaron Pfeifer wrote. It is feature-complete, supports multiple state machines per model, and works fine. The DSL is verbose and the documentation is mediocre. I use it when I inherit a codebase that already uses it.
Statesman comes from GoCardless and is event-sourcing-flavored. Instead of mutating a column, it writes every transition as a row in a *_transitions table. You get a perfect audit log for free. The downside is that you now have a separate model and joins to query current state, and the DSL is wordier than AASM. I reach for Statesman when the audit log is a hard requirement and I do not want to bolt it on with PaperTrail.
AASM is the workhorse. It is the most popular state machine gem for Rails by a wide margin, the DSL is the most readable, and it integrates with ActiveRecord cleanly. It does the basics extremely well and gets out of your way. For ninety percent of Rails state machine work, AASM is the right answer.
This post focuses on AASM. The patterns translate to the other two with mechanical changes.
AASM Basics: Order State Machine
Here is a small but realistic order state machine. It covers the path most e-commerce orders take and the failure modes that bite in production.
# Gemfile
gem "aasm"
# app/models/order.rb
class Order < ApplicationRecord
include AASM
aasm column: :status, whiny_transitions: false, no_direct_assignment: true do
state :pending, initial: true
state :paid, :shipped, :delivered, :canceled, :refunded
event :pay do
transitions from: :pending, to: :paid, guard: :payment_successful?
after do
OrderMailer.payment_received(self).deliver_later
FulfillmentJob.perform_later(id)
end
end
event :ship do
transitions from: :paid, to: :shipped, guard: :has_tracking_number?
end
event :deliver do
transitions from: :shipped, to: :delivered
end
event :cancel do
transitions from: [:pending, :paid], to: :canceled
after do
IssueRefundJob.perform_later(id) if status_was == "paid"
end
end
event :refund do
transitions from: [:paid, :shipped, :delivered], to: :refunded
end
end
private
def payment_successful?
stripe_charge_id.present? && payment_intent.status == "succeeded"
end
def has_tracking_number?
tracking_number.present?
end
end
A few details that matter.
column: :status tells AASM which column holds state. The default is aasm_state, which makes for ugly database tables. Always set this explicitly.
whiny_transitions: false makes invalid transitions return false instead of raising AASM::InvalidTransition. I prefer false because controllers can branch on it without rescuing exceptions. Set it to true if you want loud failures during development and bug-by-design behavior in production. Pick one consistently.
no_direct_assignment: true is the line most people skip and then regret. Without it, anyone can write order.status = "shipped" and the state machine is silently bypassed. With it, you get a RuntimeError if anything tries to assign the column directly. This is the line that prevented the double-charge bug in the war story above from ever happening in the first place.
Persisting State and Guards
AASM provides three methods per event: pay, pay!, and may_pay?. The bang version persists the change to the database in a transaction. The non-bang version mutates the in-memory object only. The may_*? predicate returns true if the transition is currently legal.
In production code, you want pay!. The Rails convention of ! meaning “writes to the database, may raise” carries over cleanly here.
# good — persists and fires callbacks
order.pay!
# also good — check before transitioning
if order.may_pay?
order.pay!
else
redirect_to order, alert: "Cannot pay this order in its current state"
end
# bad — mutates the object in memory only, then save bypasses guards
order.pay
order.save
Guards are predicates that must return true for the transition to fire. They run before the transition. If a guard fails, the transition returns false (or raises, depending on whiny_transitions). Guards are the right place for invariants: “an order cannot be shipped without a tracking number,” “a subscription cannot be activated without a payment method.”
What guards are not the place for: side effects. Guards must be idempotent and side-effect-free. If your guard sends an email or hits an external API, you are using AASM wrong and you will get pages at 3am when a flaky external service breaks state transitions for legitimate orders.
Callbacks and Side Effects
AASM supports before, after, before_transaction, and after_commit hooks at both the event and transition level. The most important distinction is between after and after_commit.
after runs after the transition but still inside the database transaction. after_commit runs after the transaction commits. The difference matters when you enqueue background jobs.
event :pay do
transitions from: :pending, to: :paid
after do
# BAD: job may pick this up before the transaction commits,
# see the order as still pending, and do nothing.
FulfillmentJob.perform_later(id)
end
after_commit do
# GOOD: transaction is committed, job will see the paid state.
FulfillmentJob.perform_later(id)
end
end
This is the same lesson Rails developers learn when they discover that after_create and after_save callbacks fire before the transaction commits. ActiveJob backends that pull from the same database (Solid Queue, GoodJob, delayed_job) almost always see committed state because they read in a fresh transaction. ActiveJob backends that pull from external infrastructure (Sidekiq with Redis, Amazon SQS) absolutely can race the commit. Use after_commit for anything that enqueues a background job. For the deeper rationale behind always preferring database-backed jobs for this kind of work, see my post on Solid Queue background jobs.
Testing AASM State Transitions
The fastest way to write brittle state machine tests is to mock the model. The right way is to actually transition and assert on the result. Here is the RSpec pattern I use.
# spec/models/order_spec.rb
require "rails_helper"
RSpec.describe Order, type: :model do
describe "state machine" do
let(:order) { create(:order, :pending, stripe_charge_id: "ch_123") }
it "transitions from pending to paid on successful payment" do
allow(order).to receive(:payment_successful?).and_return(true)
expect { order.pay! }.to change(order, :status)
.from("pending").to("paid")
end
it "refuses to pay when payment_successful? is false" do
allow(order).to receive(:payment_successful?).and_return(false)
expect(order.may_pay?).to be false
expect(order.pay).to be false
expect(order.reload.status).to eq("pending")
end
it "refuses to ship without a tracking number" do
order.update!(status: "paid", tracking_number: nil)
expect { order.ship! }.to raise_error(AASM::InvalidTransition)
end
it "enqueues fulfillment after commit" do
allow(order).to receive(:payment_successful?).and_return(true)
expect { order.pay! }.to have_enqueued_job(FulfillmentJob).with(order.id)
end
end
end
If your factory uses no_direct_assignment: true, you need to either set the initial state through the factory’s normal mechanism (which AASM allows for initial: true states) or use order.send(:write_attribute, :status, "paid") for setup in tests. I prefer the latter and a clearly labeled _for_test helper because it makes test setup explicit. For more on production-grade RSpec patterns, see my post on RSpec, FactoryBot, and VCR testing.
Concurrency: Optimistic Locking and Race Conditions
The double-charge bug from the opening was a concurrency bug. Two Stripe webhook deliveries arrived within 50 milliseconds of each other for the same order. Both webhook handlers loaded the order, both saw it as pending, both called order.pay!. AASM’s guard ran in both, returned true in both, and both transactions committed.
The fix is optimistic locking combined with idempotent webhook handling. Add a lock_version column to the orders table and set self.locking_optimistic = true is not needed — Rails enables it automatically when the column exists.
class AddLockVersionToOrders < ActiveRecord::Migration[8.0]
def change
add_column :orders, :lock_version, :integer, default: 0, null: false
end
end
Now the second concurrent webhook will hit ActiveRecord::StaleObjectError when it tries to save, because the version it loaded is no longer current. Wrap the webhook handler in a retry-with-reload pattern.
class StripeWebhooksController < ApplicationController
def create
event = Stripe::Webhook.construct_event(request.raw_post, ...)
case event.type
when "charge.succeeded"
handle_payment(event.data.object)
end
head :ok
end
private
def handle_payment(charge)
Order.transaction do
order = Order.lock.find_by!(stripe_charge_id: charge.id)
order.pay! if order.may_pay?
end
rescue ActiveRecord::StaleObjectError
retry
end
end
Two things are happening here. Order.lock.find_by! takes a pessimistic row-level lock, serializing concurrent handlers. order.pay! if order.may_pay? is the idempotent guard — the second handler sees the order is already paid, may_pay? returns false, and the call is a no-op. Pick one approach or use both as belt-and-suspenders, but never neither. For deeper patterns around webhook idempotency, see Rails webhook processing.
Subscription State Machine: A Real Example
Subscriptions are harder than orders because state can change based on time, not just user action. A trialing subscription becomes active when the trial ends. An active subscription becomes past_due when a renewal payment fails. A past_due subscription becomes canceled after the dunning sequence expires.
Here is the production state machine I have shipped a half-dozen times.
class Subscription < ApplicationRecord
include AASM
aasm column: :status, no_direct_assignment: true, whiny_transitions: false do
state :trialing, initial: true
state :active, :past_due, :canceled, :expired
event :activate do
transitions from: [:trialing, :past_due], to: :active,
guard: :has_valid_payment_method?
after_commit { record_activation_metric }
end
event :mark_past_due do
transitions from: :active, to: :past_due
after_commit { DunningJob.perform_later(id) }
end
event :cancel do
transitions from: [:trialing, :active, :past_due], to: :canceled
after_commit { ScheduleAccessRevocationJob.perform_later(id, at: ends_at) }
end
event :expire do
transitions from: [:past_due, :canceled], to: :expired,
guard: :access_period_ended?
end
end
scope :due_for_expiration, lambda {
where(status: [:past_due, :canceled])
.where("ends_at < ?", Time.current)
}
def access_period_ended?
ends_at.present? && ends_at < Time.current
end
end
The recurring job that drives time-based transitions is a simple cron job:
class ExpireSubscriptionsJob < ApplicationJob
queue_as :default
def perform
Subscription.due_for_expiration.find_each(&:expire!)
end
end
Schedule it daily with Solid Queue’s recurring tasks (see my post on Solid Queue recurring jobs). The cron job is dumb. The intelligence lives in the state machine’s guard. Expiration only fires when both access_period_ended? is true and the subscription is in a valid source state. A subscription that has been manually reactivated will not expire just because the cron caught it mid-flight.
When NOT to Use a State Machine
State machines are not free. They add a dependency, a learning curve for new developers, and a tendency to over-model. Three signs you do not need one:
The object has two states, and they will always be two states. A boolean column is fine. published: true | false is not a state machine.
The transitions are linear and have no guards. If status only ever marches forward from A to B to C with no business rules, a enum with a next_status! helper is simpler.
The state is derived, not stored. If you can compute the state from other fields at any time (a paid_at timestamp plus a refunded_at timestamp), you do not need a state machine — you need a method that returns the current state.
The right time to introduce a state machine is when you find yourself writing the third or fourth if status == X && other_thing check across the codebase. By then the cost of refactoring is still small and the benefit is large.
FAQ
What is the best Ruby state machine gem for Rails in 2026?
For most Rails apps, AASM remains the default choice in 2026. It has the cleanest DSL, the most active maintenance, and the best ActiveRecord integration. Reach for Statesman when audit logging of every transition is a hard requirement. Reach for state_machines when you inherit a codebase that already uses it.
Should I use an enum or AASM for status?
Use a Rails enum when you need a fixed list of string-to-integer mappings with no business logic around transitions. Use AASM when there are rules about which states can transition to which other states, when transitions need guards, or when transitions trigger side effects. The two compose well: AASM can use an enum-backed column as its state column.
How do I handle race conditions in a Rails state machine?
Combine optimistic locking (a lock_version column on the model) with idempotent transition guards (may_event? predicates that return false if the transition has already fired). For webhook handlers, additionally take a pessimistic row-level lock with Model.lock.find_by! inside a transaction. The combination prevents the double-charge class of bug.
Can AASM transitions trigger background jobs safely?
Yes, but only from after_commit callbacks, not after. Jobs enqueued in after callbacks can be picked up by a worker before the transaction commits, causing the worker to see stale state. after_commit runs after the database commits and is the safe choice for anything that enqueues work to be picked up asynchronously.
Building a Rails app where the state column is starting to feel like a free-for-all? TTB Software specializes in fractional CTO work for growing Rails teams. We have spent nineteen years cleaning up status columns and shipping the state machines that should have been there from day one.
Related Articles
Rails insert_all and upsert_all: Bulk Database Operations That Skip the ORM Overhead
Rails insert_all and upsert_all for bulk database operations: skip callbacks, handle conflicts, returning IDs, and be...
Rails PgBouncer: Transaction Pooling, Prepared Statements, and Connection Sizing in Production
Rails PgBouncer transaction pooling done right: prepared statements, connection sizing, advisory locks, LISTEN/NOTIFY...
Rack Mini Profiler: Performance Profiling for Rails in Development and Production
Rack Mini Profiler for Rails: profile SQL queries, partials, memory, and GC in development and production. Find N+1s,...