RUBY ON RAILS · 18 MIN READ ·

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.

Rails State Machine: AASM Patterns for Orders, Subscriptions, and Workflows in Production

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.

#rails-state-machine-aasm #rails-aasm-tutorial #aasm-vs-statesman-vs-state-machines #rails-order-state-machine #rails-subscription-state-machine #ruby-state-machine-gem

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software