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 stateguard: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:
| Callback | When it runs |
|---|---|
before | Before the transition executes |
after | After the transition completes |
after_all_transactions | After all database transactions commit |
before_all_transactions | Before any transaction opens |
ensure | Always, regardless of outcome |
error | If 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
statuscolumn 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
- /guides/ruby-command-pattern/: encapsulate operations as objects, a natural companion to state machine events
- /guides/ruby-decorators-pattern/: layer behaviour on top of objects without modifying their class
- /guides/ruby-service-objects/: extract complex business logic into dedicated service classes
- /guides/ruby-observer-pattern/: react to state changes with a decoupled observer pattern