State machines in Rails: how an object moves through its lifecycle
For readers who have not written Ruby, but want to understand how Rails apps describe the lifecycle of business objects - and why that almost always means a finite state machine.
Examples are cut down to the essentials. The domain is the most familiar one: an order in an online store.
Start with the pain
Almost every app has an object that does not live for a moment, but for weeks. An order. A subscription. A vacation request. An article in editorial. Such an object moves through phases, and the database gets a status or state column for that.
The first version of the code always looks like this:
order.state = 'complete'
order.save
It works. Problems arrive later, and there are three of them.
First: nothing forbids impossible transitions. An empty cart can become a completed order. A canceled subscription can become active again. The app does not know that is illegal, because you never wrote that down. The knowledge lives in the developer's head - and over time there are more heads.
Second: side effects sprawl. Completing an order should create an invoice, deduct stock, and send an email. The first time you put that in a controller. Then an admin panel appears, where a manager completes the order by hand. Then an import from an external system. Then a rake task to migrate old data. Four places where you must remember the email. They will forget it in the third one.
Third: you cannot tell where the object came from. The string 'canceled' in the database does not store whether cancellation happened before payment or after. Business cares about that difference.
A finite state machine (FSM) is a way to put all three things in one place. It has four concepts:
- states - a finite list of phases;
- events - actions that trigger a phase change;
- transitions - rules of the form "from state A on event X you may go to B";
- reactions - code that runs at the moment of transition.
Here is the order lifecycle we will unpack below:
stateDiagram-v2
[*] --> cart
cart --> payment: checkout
payment --> complete: pay
cart --> canceled: cancel
payment --> canceled: cancel
complete --> canceled: cancel if cancelable?
complete --> [*]
canceled --> [*]
Further in the text: how this looks in Rails, what happens under the hood, and where people usually get burned.
Three ways to store state in Rails
Before you pick a gem, it helps to know the alternatives. There are effectively three.
A string column and constants. The simplest option, no dependencies.
class Order < ApplicationRecord
STATES = %w[cart address payment complete canceled].freeze
validates :state, inclusion: { in: STATES }
end
You get a list of allowed values, but neither transition rules nor reactions. Fine if there are three phases and the transitions are obvious.
enum from Rails itself. Built-in mechanism, available since Rails 4.1.
class Order < ApplicationRecord
enum state: { cart: 0, payment: 1, complete: 2, canceled: 3 }
end
You get order.complete?, order.complete!, the Order.complete scope, and a clean "symbol in code - number in DB" mapping for free (docs). But enum only knows about values. It will happily move an order from cart straight to complete, because it has no notion of a "transition".
A gem with a finite state machine. There is a real choice here, and the choice shapes how the whole model looks:
| Gem | Why it is interesting |
|---|---|
state_machines-activerecord |
Successor of the historical state_machine, the first popular gem in this niche. Rich DSL, state in a model column. Common in older projects and in Spree/Solidus. |
aasm |
Today the most common choice for new code. Same set of ideas, a slightly more compact syntax, active maintenance. |
statesman |
From the payments company GoCardless. The main difference: every transition is written as a separate row in a transitions table, and the current state is the latest record. Change history comes for free, which matters in financial systems. |
workflow |
A veteran: compact, with little magic. Rare in the wild, but the code reads easily. |
Below I show the syntax of state_machines, because it is the most "talkative" and makes the DSL structure clearest. Everything said transfers to aasm almost literally - only the keyword names change.
| Concern | enum |
state-machine gem |
|---|---|---|
| List of allowed values | yes | yes |
Query methods complete? |
yes | yes |
Scopes Order.complete |
yes | yes (also in state_machines) |
| Rules for "from where to where" | no | yes |
| Conditions on a transition | no | yes |
| Code at transition time | no (only general model callbacks) | yes |
Practical advice: if the object's description contains the word "cannot" ("cannot cancel after shipment") - take a state machine. If state is just a label for filtering a list - enum is enough.
Where state physically lives
There is no magic here, and that is important to understand up front. State is an ordinary column:
CREATE TABLE orders (
id bigserial PRIMARY KEY,
state character varying(255),
...
);
The gem reads and writes the same column you do. No separate table, no clever format. The difference is discipline: you should no longer write to it directly. Instead of order.state = 'complete' you fire an event, and the gem first checks whether the transition is allowed, then changes the value.
Two immediate conclusions that will help later:
Order.where(state: 'complete')keeps working as a normal SQL query. The machine does not break querying.order.update_column(:state, 'complete')bypasses the machine entirely. Sometimes you need that (data migrations, fixtures), but more on that in the pitfalls section.
The exception is statesman: there the current state is derived from the transitions table. For the other gems in the list above, the picture is exactly as above - one column, one value.
Ruby DSL: what is this, really
Here is a minimal machine in full:
class Order < ApplicationRecord
state_machine :state, initial: :cart do
event :checkout do
transition cart: :payment
end
event :pay do
transition payment: :complete
end
event :cancel do
transition [:cart, :payment, :complete] => :canceled, if: :cancelable?
end
after_transition to: :complete, do: :send_confirmation
end
end
It reads almost like a technical brief: "the order starts in the cart; the checkout event moves it from cart to payment; pay - from payment to complete; cancel moves it to canceled from any of the three phases if the object says cancellation is allowed; after reaching complete we send a confirmation".
For someone coming from Java, C#, Go, or TypeScript, there is one thing to understand here, and it removes about 80% of the confusion when reading Ruby:
This is not special language syntax and not a config file. These are ordinary method calls.
Line by line, what is actually happening:
| Line in the DSL | What it really is |
|---|---|
state_machine :state, initial: :cart do ... end |
a call to method state_machine with two arguments (a symbol and the hash {initial: :cart}) and a code block |
:cart |
a symbol (Symbol) - an immutable string-like identifier, analogous to an enum constant; cheaper than a regular string |
event :checkout do ... end |
a call to method event; the block runs in the context of an event builder object |
transition cart: :payment |
a call to method transition with a hash; key is the source state, value is the target |
[:cart, :payment] => :canceled |
the same hash, but the key is an array: "from any of these" |
if: :cancelable? |
a symbol instead of a function: "when the time comes, call the method with this name on the object" |
after_transition to: :complete, do: :send_confirmation |
another method call that registers a handler |
This works because of two Ruby properties most familiar languages do not have:
Parentheses on a method call are optional. puts "hi" and puts("hi") are the same thing. So event :checkout looks like a keyword, but it is really event(:checkout).
Any method can take a block of code. A block is a chunk of code in do ... end or { ... } that the method can run when it wants, in whatever context it wants. The gem takes your block and evaluates it on its builder object, which is why inside the block you suddenly have event and transition methods that the model itself does not have.
From these two properties grow DSLs - "languages inside the language". You have already seen them, even if you did not know the name: has_many :comments, validates :email, presence: true, before_action :authenticate - all of these are exactly the same ordinary method calls.
Practical takeaway for reading unfamiliar Ruby: if a line is unclear, ask not "what syntax is this" but "which method is being called, and with which arguments". The answer almost always lives in the gem docs, not in the language reference.
What the gem writes for you
Once you declare a machine, you get a set of methods that are not in the source. They are created when the class loads - that is called metaprogramming, and in Rails it is everywhere.
For the event pay and the state complete you get:
| Method | What it does |
|---|---|
order.complete? |
a predicate - checks the current state, true or false |
order.pay |
tries to perform the transition; on failure returns false and changes nothing |
order.pay! |
the same, but on failure raises an exception |
order.can_pay? |
whether the event can run right now, without running it |
order.state_transitions |
the list of transitions available from here |
Order.with_state(:complete) |
a query scope |
Three notes, each of which saves an hour of confusion.
About the bang. pay! and pay are two different methods; ! is part of the name, not an operator. Ruby has a convention: the version with ! is more dangerous than the one without. What "more dangerous" means depends on the library. Here it means "raises instead of quietly returning false". In ActiveRecord, save! versus save is exactly the same pair. The choice is simple: pay! where an impossible transition is a bug and should fail; pay where it is a normal scenario and you check the result.
About grep. Searching the project for def pay! finds nothing. The method is not written by hand; it is generated. For a Ruby newcomer this is the most common moment of confusion: the method is called, it works, and there is no definition. The cure is a habit: if you cannot find a definition, look for the declaration (state_machine, has_many, enum, delegate) that produced it. The console also helps: order.method(:pay!).source_location shows which gem file the method came from.
About can_pay?. The most underrated method in the list. It answers "will this work" without attempting it, and is ideal for two things: showing or hiding a button in the UI, and checking freshness in a background job.
class OrderPaymentJob
def perform(order_id)
order = Order.find(order_id)
# time passed between enqueue and execution;
# someone may have canceled the order in admin
return unless order.can_pay?
order.pay!
end
end
This is the classic background-job problem: between enqueue and execution the world changes. The machine gives a cheap way to check that the job still makes sense.
The trap of similar names
The machine generates a predicate named after the state. Developers also write their own predicates by hand. The names look alike, the meanings differ:
# generated by the machine: state == 'complete'
order.complete?
# written by hand somewhere in the model: is the date column filled
def completed?
completed_at.present?
end
Usually they agree, but they are two sources of truth, and when they drift you will stare at the code for a long time. Reading rule: when you see a predicate, first find out whether it is hand-written or generated. If grep finds no def - it is a state.
Guard conditions: where the word "cannot" lives
A guard is a check attached to a transition. If it returns false, there is no transition.
event :cancel do
transition [:cart, :payment, :complete] => :canceled, if: :cancelable?
end
def cancelable?
return false if canceled?
shipment.nil? || shipment.pending?
end
The business rule "you cannot cancel an order that has already shipped" is written exactly once. From then on it applies everywhere: controller, admin, API, rake task. Nobody can accidentally bypass it, because there is nothing to bypass - the check is built into the transition itself.
What happens on failure:
order.cancel # => false, state did not change
order.cancel! # => exception StateMachines::InvalidTransition
order.reload.state # => 'complete', as before
A guard can be defined in two interchangeable ways:
transition complete: :canceled, if: :cancelable? # symbol = method name
transition complete: :canceled, if: ->(order) { order.paid? } # lambda
A lambda is an anonymous function as an object: you can put it in a variable, pass it as an argument, and call it later. The gem stores it when the class loads and invokes it at transition time. A symbol is essentially the same thing, just shorter; use a lambda for a one-off condition, and a method when you want to reuse it or test it separately.
An important distinction people often mix up:
- a validation answers "is the data correct?" (
validates :email, presence: true); - a guard answers "is this action applicable to the object in its current state?".
An order without a shipping address is invalid. An order that has already shipped is perfectly valid - you just cannot cancel it. Different questions, different mechanisms.
Worth noting separately: for the cancel event in the example the source states are listed, but you can omit them.
event :cancel do
transition to: :canceled, if: :cancelable?
end
Then you can transition from any state, and the guard does all the filtering. People write it this way when the list of source states is long and awkward to maintain. The cost: the rule is no longer visible in the declaration; you have to go read the method.
Transition reactions: before and after
Callbacks are "what happens when". There are two kinds, and the difference between them is fundamental.
state_machine :state, initial: :cart do
before_transition to: :complete do |order|
order.charge_payment! # returned false -> no transition
end
after_transition to: :complete, do: :send_confirmation
after_transition to: :canceled, do: :restock_items
end
before_transition runs before the state changes and can cancel it: if the block returns false, the transition does not happen and the object stays as it was. Put here whatever the transition cannot make sense without. Charging money is the canonical example: no payment, no completed order.
after_transition runs after the state has already changed, and cannot cancel the transition. Put consequences here: emails, documents, notifications into a queue.
sequenceDiagram
participant App as order.pay!
participant Guard as guard / before
participant DB as state column
participant After as after_transition
App->>Guard: can_pay? and before_transition
alt before returned false
Guard-->>App: transition canceled
else all good
Guard->>DB: payment -> complete
DB->>After: finalize, invoice, enqueue...
After-->>App: done (inside the same transaction)
end
Simple selection rule: if the answer to "if this fails, should the transition be undone?" is yes - use before. If no - use after.
Ordering and invisible subscribers
Callbacks run in registration order. With two of them, that does not matter. The problem appears in a living project where the same transition is subscribed from different files - from the model, from included modules (concerns), from decorators of third-party gems:
# order.rb
after_transition to: :complete, do: :finalize!
# order/invoiceable.rb (concern)
after_transition to: :complete, do: :create_invoice
# order/loyalty.rb (concern)
after_transition to: :complete, do: :award_points
Formally everything is fine. Practically - when you add a fifth callback, you do not see the other four. Their execution order depends on file load order, which in Rails is not always obvious. To learn what actually happens when an order completes, you have to grep for after_transition and assemble the picture in your head.
That is not a reason to avoid callbacks. It is a reason to keep their number under control and periodically ask yourself: is what I am adding an inseparable consequence of the transition, or a separate business process that merely starts at this moment? The first is a callback. The second is better called explicitly from a service object, where the whole sequence is visible top to bottom.
End-to-end story: payment, invoice, email
Let us assemble a typical path in full. It appears in almost every store, and it is a good place to show the main problem of pairing "state machine plus background jobs".
The customer returns from the payment gateway. The controller does two lines:
payment.complete!
order.pay!
Then a chain starts that is worth seeing once in full:
flowchart TD
A[Customer returned from payment gateway] --> B["controller: order.pay!"]
B --> TX
subgraph TX["DB transaction"]
direction TB
C["before_transition to: complete<br/>charge_payment!"]
C -->|false| X[transition canceled]
C -->|ok| D["UPDATE state:<br/>payment -> complete"]
D --> E["after_transition"]
E --> E1[finalize!]
E --> E2[create_invoice]
E --> E3[reserve_stock]
E --> E4["enqueue email ← DANGEROUS SPOT"]
end
TX --> F[COMMIT]
F --> G[Sidekiq: another process, another connection]
G --> H[ConfirmationEmailJob]
H --> I["Order.find(id) - will it see the order?"]
I --> J[email reaches the customer]
The key point of the diagram is the transaction box. The state transition and all of its callbacks run inside one DB transaction. Until COMMIT happens, the changes do not exist for the outside world.
And Sidekiq is the outside world. A separate process, a separate database connection, a separate queue in Redis. From that follows the main trap of the whole topic; the next section is about it.
What you should not copy blindly
Next - three things that show up regularly in real projects, work for years, and are still compromises rather than examples. If you write new code, look at them carefully.
1. Side effects inside the transition transaction
This is mistake number one, and it is sneaky because it does not reproduce on a developer's machine.
after_transition to: :complete do |order|
ConfirmationEmailJob.perform_async(order.id) # bad
end
What happens: perform_async puts the job into Redis immediately. The DB transaction is still open. A Sidekiq worker in another process wakes up within milliseconds, does Order.find(id) with its own connection, and does not see uncommitted changes. In the best case it reads the old state and sends an "your order is paid" email about an order that is still payment in the database. In the worst case - if the record was created in the same transaction - it gets ActiveRecord::RecordNotFound.
sequenceDiagram
participant C as Controller
participant DB as DB transaction
participant R as Redis
participant W as Sidekiq worker
C->>DB: order.pay!
Note over DB: state is already complete in this transaction
DB->>R: perform_async(order.id)
R->>W: job starts too early
W->>DB: Order.find(id)
Note over W,DB: worker does not see uncommitted data
DB-->>C: COMMIT
Locally you do not see it: on an empty database the transaction commits in microseconds, and in development Sidekiq often runs synchronously. The bug shows up in production under load, floats, and looks inexplicable.
The first thing people invent when they hit the problem is a delay:
after_transition to: :complete do |order|
ConfirmationEmailJob.perform_at(30.seconds.from_now, order.id) # also bad
end
Sometimes this lives in projects for years. The 30 seconds are not a business requirement; they are a buffer for the commit. It is a race with a timer: under load a long transaction may not finish in time, and in the normal case the customer waits an extra half minute for an email for no reason. If you see an unexplained delay before enqueueing a job in someone else's code - you are almost certainly looking at this workaround.
There are two correct solutions.
after_commit - an ActiveRecord hook that fires only after a successful commit. In the transition callback you only set an in-memory flag on the object, and you enqueue outside the transaction:
class Order < ApplicationRecord
state_machine :state, initial: :cart do
after_transition to: :complete do |order|
# do not enqueue here: the transaction is not committed yet
order.instance_variable_set(:@send_confirmation_after_commit, true)
end
end
after_commit :enqueue_confirmation
private
def enqueue_confirmation
return unless @send_confirmation_after_commit
@send_confirmation_after_commit = false
ConfirmationEmailJob.perform_async(id)
end
end
It looks more verbose than a one-liner with perform_async, but it works deterministically.
A transactional queue. If the queue lives in the same database as the data (solid_queue in Rails 8, good_job, delayed_job), the job is written by the same transaction and the problem disappears by construction: no commit means no job. That is one of the main reasons projects leave Sidekiq for a Postgres-backed queue.
Rule: from a transition callback, a job goes into a Redis queue only through after_commit.
The same transaction has a second edge: an exception inside after_transition will roll back the transition itself.
after_transition to: :complete, do: :generate_pdf_invoice # risky
PDF generation failed - the order did not complete. The customer paid, but the database still says payment. A failure in a secondary function took down the primary one. Same conclusion: the farther a side effect is from the transaction, the less it can hurt it. A background job moved out through after_commit solves both problems at once.
Two related rules about the jobs themselves, since we are here:
- Pass
id, not the object. First, only JSON serializes into the queue (numbers, strings, arrays, hashes). Second, an object that sat in the queue for a minute is stale anyway - the worker should read fresh data. - The job must be ready to run twice. Queues guarantee "at least once", not "exactly once", and Sidekiq by default retries a failed job up to 25 times. The simplest protection against a duplicate email is a sent-at column and a check before sending.
2. Writing the state column directly
order.update_column(:state, 'complete') # past the machine
This is not always a mistake. In a data migration or in a factory for testing totals - exactly what you want: quickly get an object in the right state without side effects.
But keep two consequences in mind.
No callback will run. The order becomes complete, with no invoice and no email. In tests this sometimes creates a false sense that the transition was tested: the state in the database is correct, but there was no transition.
The allowed-transition check will not run either. You can end up with an object in a combination the process never intended, and discover it six months later in a report.
Practical rule: direct writes are allowed where you prepare data, and forbidden where you handle a business event.
3. The machine as a replacement for the service layer
The last one is not about a specific line of code, but about measure. A machine is good while it describes a lifecycle. It becomes bad when the entire business process moves into it.
Symptoms that it is time to stop: one transition has a dozen subscribers; callback order has to be set by hand; a guard in one callback checks the result of another; to understand what order.pay! does, you must open six files.
The usual cure is one: leave state change and what is inseparable from it in the machine, and gather the rest into an explicit service object where the step sequence is visible top to bottom:
class CompleteOrder
def call(order)
order.pay! # the machine owns only the state
InvoiceCreator.new.call(order)
StockReserver.new.call(order)
ConfirmationEmailJob.perform_async(order.id)
end
end
It reads in ten seconds, debugs step by step, tests in parts. Boring - and that is the virtue.
How to cover this with tests
State-machine tests fall into three groups, and all three check observable behavior, not internals.
First: the transition is allowed from the right states. The cheapest test; no side effects at all:
RSpec.describe Order do
it 'allows cancelling a fresh order' do
order = create(:order, state: 'payment')
expect(order.can_cancel?).to be true
end
end
Second: the guard really blocks. Check both the return value and that the state did not move:
it 'refuses to cancel a shipped order' do
order = create(:order, :shipped)
expect(order.cancel).to be false
expect(order.reload.state).to eq('complete')
end
reload here is not a formality: it re-reads the object from the database and proves that nothing changed there either, not only in memory.
Third: the transition's side effect happened. The main test of this article's story:
RSpec.describe Order do
describe 'transition to complete' do
let(:order) { create(:order, :ready_for_payment) }
it 'creates an invoice' do
expect { order.pay! }.to change { order.invoices.count }.by(1)
end
it 'enqueues the confirmation email' do
expect { order.pay! }
.to change(ConfirmationEmailJob.jobs, :size).by(1)
end
end
end
Three notes on this set.
About the factory. :ready_for_payment should build an order that can actually transition to complete: with line items, address, shipment, and payment. The temptation to shorten it to create(:order, state: 'payment') is strong, but then a guard will hit missing data, and the test will check something other than what you think. An order whose state was set directly is useless for a transition test: there was no transition.
About change. The matcher expect { ... }.to change { ... }.by(1) measures the value before and after the block. It checks the result, not how it was achieved, so it survives internal refactors.
What not to do. Do not assert that an internal method was called, and do not mock the worker:
# do not: the test is tied to the current implementation
expect(order).to receive(:create_invoice)
expect(InvoiceJob).to receive(:perform_async)
In a year the chain will change - another class will create the invoice, the job will go through after_commit - and such a test will break even though the behavior stayed correct. Assert what the outside world sees: a row in the database, the queue size, a response body.
Glossary
| Term | Meaning |
|---|---|
| State machine (finite state machine) | A process model: a finite set of states plus rules for moving between them. In Rails implemented by gems like state_machines-activerecord or aasm. |
| State | A named phase of an object's life: cart, complete, canceled. Usually stored as a string in a DB column. |
| Event | A named action that triggers a transition: pay, cancel. Produces methods pay, pay!, can_pay?. |
| Transition | A rule "from states A, B you may go to C", declared inside an event. A transition not described by a rule is impossible. |
| Guard | An if: / unless: condition on a transition. False means no transition. Answers "is the action applicable", unlike a validation, which checks data correctness. |
| Predicate | A yes/no method returning true/false: complete?. Predicates generated by the machine cannot be found by searching for def. |
Bang method (pay!) |
Ruby convention: the method version that raises on failure instead of returning false. The exclamation mark is part of the name, not an operator. |
| Callback | Code tied to a moment of transition. before_transition runs before the state change and can cancel it; after_transition runs after and cannot cancel. |
| DSL (domain-specific language) | A "language inside the language". In Ruby - ordinary method calls with blocks that, thanks to optional parentheses, read as a declarative description. |
Symbol (:complete) |
An immutable string-like identifier. Cheaper than a regular string; used as a state name, event name, or method name. |
| Lambda / Proc | An anonymous function as an object: can be stored in a variable, passed as an argument, and called later. Guards rest on this. |
| Metaprogramming | Creating methods at runtime. The reason pay! works even though its definition is not in your code. |
| Worker / Job (Sidekiq) | A class with a perform method, executed in a separate process via a Redis queue. Accepts only JSON-compatible arguments, so you pass id, not the object. |
after_commit |
An ActiveRecord hook that fires after a successful transaction commit. The only safe place to enqueue a job into a Redis queue. |
What to read next
state_machinesandaasm- the syntax differs, the ideas are the same; it is useful to skim both READMEs back to back to separate the concept from a concrete API.statesman- look even if you do not plan to use it: storing transitions in a separate table changes how you see the problem. State stops being a value and becomes a consequence of history.- ActiveRecord::Enum - if after this article it seems you do not need a machine, that is an honest and correct conclusion; start here.
after_commitand other ActiveRecord callbacks - the section on transactional hooks is worth reading in full.- Recommended continuation: one object usually has several machines (
Order,Payment,Shipment), and they are not synchronized with each other. "Order complete" does not mean "money received", and "money received" does not mean "parcel shipped". The most expensive bugs live exactly at the seams between these machines.