rubyguides

State machines in Ruby with AASM: a practical guide

State machines are one of the most useful design patterns in application development. They let you model the lifecycle of an object with precision — defining exactly which states it can be in, which transitions are allowed, and what happens when a transition fires.

This guide covers the AASM gem (formerly known as acts_as_state_machine), the de facto standard for state machines in Ruby.

What is a state machine?

A state machine describes how an object moves through a finite set of states. Each state represents a condition or mode the object can be in. Transitions are the allowed movements between states, triggered by events.

A classic example is an order in an e-commerce system:

[pending] --pay--> [paid] --ship--> [shipped] --deliver--> [delivered]
                  |
                  +--cancel--> [cancelled]

Without a state machine, you’d likely scatter status checks and assignments across your codebase. With one, the valid paths are enforced in one place. The code becomes self-documenting because the state definitions declare upfront what transitions are legal, rather than hiding those rules behind scattered conditionals in controllers or service objects.

Installing AASM

AASM ships as a standalone gem with no hard dependencies beyond Ruby itself. It works with plain Ruby objects out of the box and has optional ActiveRecord integration for Rails applications. Add the gem to your Gemfile to get started:

gem 'aasm'

Bundler will fetch the gem and resolve any transitive dependencies. Once AASM is in your Gemfile and installed, you can start defining state machines on any Ruby class. The gem uses the aasm DSL to declare states and events inside a block that reads naturally alongside the rest of your class definition. Run the install command to make AASM available in your project:

bundle install

Defining states and events

The core of any AASM state machine is the aasm block, where you declare states with symbolic names and events that govern how the object moves between them. Below is a minimal example using a Ticket class that models a simple support workflow with four states:

require 'aasm'

class Ticket
  include AASM

  attr_reader :id, :title

  def initialize(id, title)
    @id = id
    @title = title
  end

  aasm do
    state :open, initial: true
    state :in_progress
    state :resolved
    state :closed

    event :start do
      transitions from: :open, to: :in_progress
    end

    event :resolve do
      transitions from: :in_progress, to: :resolved
    end

    event :close do
      transitions from: [:resolved, :in_progress], to: :closed
    end

    event :reopen do
      transitions from: [:closed, :resolved], to: :open
    end
  end
end

The aasm block declares all states and events. open is the initial state, meaning new instances start there automatically.

Transitions

Transitions map events to state changes. The transitions method accepts:

  • from: the source state (or array of states)
  • to: the destination state
  • guard: a condition that must be true for the transition to succeed (more on this below)
  • after: a callback that runs after the transition completes
event :resolve do
  transitions from: :in_progress, to: :resolved
end

Multiple source states are supported, which is useful when an event should be valid from more than one starting point. The close event below can fire from either resolved or in_progress, reflecting the real-world rule that a ticket can be closed at different stages:

event :close do
  transitions from: [:resolved, :in_progress], to: :closed
end

Guards (conditions on transitions)

Guards add conditional logic to transitions without scattering that logic into controllers or services. When a guard method returns false, the transition is silently rejected and the object remains in its current state. This keeps the business rules at the model level where they belong. You can pass a symbol referencing a method or an inline lambda:

class Order
  include AASM

  attr_accessor :payment_received

  aasm do
    state :pending, initial: true
    state :paid
    state :shipped
    state :delivered

    event :mark_paid, guards: :payment_received? do
      transitions from: :pending, to: :paid
    end

    event :ship do
      transitions from: :paid, to: :shipped
    end

    event :deliver do
      transitions from: :shipped, to: :delivered
    end
  end

  def payment_received?
    payment_received == true
  end
end

The guard method payment_received? is evaluated before the transition executes, and AASM uses its return value to decide whether to proceed. This pattern keeps validation logic right next to the state definition rather than buried in a controller or service. Here is how the guard works in practice — the mark_paid event only succeeds when payment has been confirmed:

order = Order.new
order.mark_paid? # => false
order.payment_received = true
order.mark_paid   # transitions to :paid
order.mark_paid?  # => true

If a guard returns false, the transition is rejected and the object stays in its current state. You can check may_mark_paid? to ask whether the transition is currently allowed without triggering it.

Callbacks

AASM provides callbacks that run at specific points around transitions:

CallbackWhen it runs
beforeBefore the transition executes
afterAfter the transition completes
after_all_transactionsAfter all database transactions commit
before_all_transactionsBefore any transaction opens
ensureAlways, regardless of outcome
errorIf an exception is raised during the transition
class Document
  include AASM

  attr_accessor :content, :version

  aasm do
    state :draft, initial: true
    state :review
    state :published

    event :submit_for_review do
      transitions from: :draft, to: :review
    end

    event :publish do
      transitions from: :review, to: :published,
                  after: :increment_version
    end

    event :revise do
      transitions from: :published, to: :draft
    end
  end

  private

  def increment_version
    self.version ||= 0
    self.version += 1
  end
end

The Document class defines three states (draft, review, published) and three transitions. Notice how the publish event uses the after callback to increment a version counter — this keeps the side effect coupled to the transition where it logically belongs. Here is how the lifecycle plays out when you create and progress a document:

doc = Document.new
doc.version # => nil
doc.submit_for_review
doc.publish
doc.version # => 1

Callbacks are not limited to the after hook. You can attach behaviour that fires before a transition, on error, or unconditionally in all cases. For instance, the archive event below runs a notification before the transition and cleans up assets after it completes:

event :archive do
  transitions from: [:published, :draft], to: :archived,
              before: :send_archive_notification,
              after: :cleanup_assets
end

Auto-Created Methods

When you define a state machine, AASM generates convenience methods for every event and state you declare. These let you query the current state, test whether a transition is possible, and fire events without writing boilerplate accessors yourself.

Event methods (trigger transitions):

For each event like start, AASM gives you three related methods on every instance:

ticket.start       # fires the start event
ticket.start!      # fires and raises error on failure (bang version)
ticket.may_start?  # returns true if the transition is currently allowed
ticket.started?    # predicate: true if the current state is :started

Bang methods raise an AASM::InvalidTransition if the transition is not allowed. Non-bang versions silently do nothing when the transition cannot be made. The bang variant is useful when you want to surface invalid state changes as exceptions, while the non-bang form works well for conditional workflows where a no-op is acceptable.

State predicates:

AASM also generates a boolean predicate for every state, so you can check the object’s current mode without comparing strings or symbols:

ticket.open?        # true if in :open state
ticket.in_progress? # true if in :in_progress state
ticket.closed?      # true if in :closed state

Transition history:

The aasm accessor on each instance exposes a full introspection API that you can use to query the current state, enumerate all defined states and events, and check which transitions are available for the object in its current state. This is especially useful for debugging or building dynamic UI elements that adapt to the object’s lifecycle position:

ticket.aasm.current_state   # => :open
ticket.aasm.states           # => [#<AASM::State>, ...]
ticket.aasm.events           # => [#<AASM::Event>, ...]

Integrating with ActiveRecord

AASM integrates with ActiveRecord by mapping the state machine column to a database field. Every successful transition automatically persists the new state within a transaction, so you never need to call save manually after firing an event. Start by adding a column to hold the state value:

class AddStateToOrders < ActiveRecord::Migration[7.0]
  def change
    add_column :orders, :state, :string, default: 'pending', null: false
    add_index :orders, :state
  end
end

With the column in place, include AASM in your model and point it at the database column that should hold the state. The gem wraps every state change in an ActiveRecord transaction, so the column and any associated changes are atomically persisted or rolled back together:

class Order < ApplicationRecord
  include AASM

  aasm column: :state do
    state :pending, initial: true
    state :paid
    state :shipped
    state :delivered
    state :cancelled

    event :pay, guards: :payment_valid? do
      transitions from: :pending, to: :paid
    end

    event :ship do
      transitions from: :paid, to: :shipped
    end

    event :deliver do
      transitions from: :shipped, to: :delivered
    end

    event :cancel do
      transitions from: [:pending, :paid], to: :cancelled
    end
  end

  private

  def payment_valid?
    payment_token.present? && amount_cents > 0
  end
end

AASM will automatically persist the new state after a successful transition within an ActiveRecord transaction. This means you can fire events on your model without calling save or worrying about partial state changes — the gem wraps every transition in a database transaction and rolls back if anything goes wrong. If you need to handle persistence manually or conditionally, you can disable automatic saving:

aasm column: :state, skip_validation_on_save: true do
  # ...
end

The skip_validation_on_save option is useful when you want to defer the actual database write to a later point in the request cycle, or when you are batching multiple state transitions and want to save once at the end. For most applications, however, the default transactional behaviour is the safest choice.

Whiny Mode

By default, AASM raises AASM::InvalidTransition when you try to fire an unavailable event. This “whiny” behaviour surfaces programming errors early by throwing an exception rather than silently ignoring a bad transition. You can disable it if you prefer a no-op approach:

aasm whiny_transitions: false do
  # ...
end

With whiny mode off, invalid transitions silently no-op. This can be useful in forms or API endpoints where you want to attempt a transition optimistically and not worry about whether the object is in the right state.

Visualising state machines

For complex machines with many states and transitions, a visual diagram makes the lifecycle much easier to reason about. You can generate a Graphviz DOT representation programmatically and render it to an image:

puts Order.aasm.state_machine.graph.output

The DOT output describes every state as a node and every transition as a directed edge, complete with event labels. Pipe it through the dot command-line tool to produce a PNG or SVG of your state diagram. This is especially helpful during code review, when a diagram can quickly reveal missing transitions or unreachable states.

Alternatively, tools like Mermaid can render state diagrams from a simple text description:

stateDiagram-v2
  [*] --> pending
  pending --> paid : pay
  paid --> shipped : ship
  shipped --> delivered : deliver
  pending --> cancelled : cancel
  paid --> cancelled : cancel

When to use a state machine

A state machine is the right tool when:

  • An object has a finite, well-defined set of states
  • Transitions between states have business rules attached to them
  • You want to enforce valid paths and prevent invalid ones
  • You need auditability: knowing what state something is in and how it got there
  • Your domain has concurrency concerns; AASM’s transaction support helps here

Signs you might need a state machine:

# Without a state machine: easy to put the object in a bad state
order.status = "delivered"
order.status = "pending" # wait, that shouldn't be allowed

# With a state machine: invalid transitions are impossible
order.deliver   # valid: shipped -> delivered
order.pending!  # raises AASM::InvalidTransition

Common use cases include:

  • Order and payment processing pipelines
  • Job or task status tracking
  • Subscription and billing lifecycles
  • Document approval workflows
  • Server or deployment state

AASM vs other options

AASM is the most widely-used state machine gem in the Ruby ecosystem. Alternatives worth knowing:

  • state_machines: a more Rails-integrated alternative (now largely unmaintained)
  • workflow: a lighter, simpler option
  • Roll your own: for very simple cases, a status column and a few scopes may be enough

AASM strikes the best balance of features, flexibility, and active maintenance for most applications.

Key takeaways

  • A state machine makes the valid lifecycle of an object explicit.
  • Events move an object from one state to another, and transitions define the allowed paths.
  • Guards and callbacks keep business rules close to the state definitions.
  • AASM works well with ActiveRecord, but it is also useful for plain Ruby objects.
  • State machines are most helpful when the domain has a small set of meaningful states and a few legal transitions between them.

Common mistakes

One common mistake is using a state machine for data that does not have a real lifecycle. If every branch is just a yes-or-no check, a state machine adds ceremony without much value.

Another pitfall is letting callbacks grow into business logic dumps. Callbacks should stay focused on small, predictable side effects such as logging, counters, or notifications. If the logic becomes complicated, move it into a service object or a dedicated method.

A frequent oversight is forgetting to document the allowed transitions. If a new developer has to read every event block just to understand what can happen next, the model is probably too hidden. A short summary of the lifecycle usually helps.

Frequently asked questions

Do I need AASM for every status field?

No. A simple status column and a scope or two is often enough. Use AASM when the transitions matter, the states are meaningful to the business, and you want the model to enforce the rules.

Can I use state machines without ActiveRecord?

Yes. AASM works with plain Ruby objects as well. ActiveRecord integration is convenient, but it is not required for the pattern itself.

What should I test first?

Test the allowed transitions and the blocked transitions. That gives you confidence that the lifecycle behaves the way you expect and that invalid paths remain impossible.

Conclusion

AASM gives you a clear way to model lifecycles in Ruby. The states, events, guards, and callbacks all live in one place, which makes the behavior easier to read and easier to change later.

If the object in question has a genuine lifecycle, a state machine can keep the rules honest and the code easier to reason about. If the domain is simpler than that, a plain column or a few methods may be all you need.

See Also