A State Machine in 277 Lines

We needed state transitions with guards, timestamps, callbacks, and instrumentation. AASM was too much. So we wrote our own.

Every model with a status column eventually grows if statements. Can a draft payroll be approved? Can a cancelled subscription be reactivated? Can a blocked todo be completed? The logic scatters across controllers, operations, and model methods until nobody can trace which transitions are valid without reading every callsite.

State machine gems solve this. We wanted something smaller.

What We Wanted

A small DSL that declares valid transitions, runs callbacks inside a transaction, and raises when you try something illegal. Nothing else. No configuration files. No initializer. No state history tables. No event hooks DSL. No serialization concerns.

class Todo < ApplicationRecord
include LiteState
transition :start, from: :pending, to: :doing, column: :status do
self.started_at = Time.current
self.started_by = Current.user
end
end

That’s the interface. Declare a transition, get an instance method. todo.start either succeeds inside a transaction or raises LiteState::TransitionError. No ceremony.

The DSL

Several models use LiteState across our codebase. Here’s how the Todo model declares its transitions:

class Todo < ApplicationRecord
include LiteState
enum :status, %w[pending doing completed dismissed blocked].index_by(&:itself)
transition :start, from: :pending, to: :doing, column: :status do
self.started_at = Time.current
self.started_by = Current.user
self.snoozed_until = nil
track_event(:started)
end
transition :complete, from: [:pending, :doing], to: :completed, column: :status do
self.completed_at = Time.current
self.completed_by = Current.user
self.progress = 100
track_event(:completed)
unblock_followers
end
transition :unblock, from: :blocked, to: :pending, column: :status do
track_event(:unblocked, predecessor_id: follows_id)
end
transition :dismiss, from: [:pending, :doing], to: :dismissed, column: :status do
self.dismissed_at = Time.current
self.dismissed_by = Current.user
track_event(:dismissed)
end
transition :reopen, from: :completed, to: :doing, column: :status do
self.completed_at = nil
self.completed_by = nil
track_event(:reopened)
end
end

Five transitions. Each declares where it can start (from:), where it ends (to:), and what happens in between. The block runs inside the same transaction that updates the status column. If the block raises, the whole thing rolls back.

from: takes a symbol or an array. :complete can fire from :pending or :doing. :start can only fire from :pending. Try todo.start on a :doing todo and you get a TransitionError with the record, the current state, the target state, and the transition name.

What transition Generates

Each transition call defines an instance method:

transition :start, from: :pending, to: :doing, column: :status do
# ...
end

This generates todo.start - a method that calls the internal transition_state engine. The engine does five things in order:

  1. Read the current state from the column
  2. Check it’s in the allowed_from list
  3. Run the guard (if one exists)
  4. Open a transaction, update the column, run the callback block
  5. Publish an ActiveSupport::Notifications event

If step 1, 2, or 3 fails: TransitionError. If the block raises: rollback, then re-raise. If everything succeeds: the notification fires with todo.start.success as the event name.

Guards

Some transitions are conditional. A subscription can only be reactivated if the account is eligible:

transition :reactivate,
from: [:suspended, :terminated],
to: :enrolled,
guard: :eligible_for_reactivation? do
clear_suspension_reason
end

The guard runs before the transaction opens. If it returns falsy, TransitionError. No side effects, no rollback needed - the guard is a pure check.

can_transition? exposes the same logic without executing:

if employee.can_transition?(:reactivate)
# show the button
end

It checks the current state against allowed_from and evaluates the guard. No transaction, no callback, no notification. A read-only probe.

Timestamps

Transitions often stamp a column when they fire. The timestamp: option handles this:

transition :succeed, from: :processing, to: :succeeded,
column: :status, timestamp: :succeeded_at do
emit_async :payment_succeeded, self
end

timestamp: :succeeded_at sets that column to Time.current as part of the same update! call that changes the status. One write, not two. Pass timestamp: true and it auto-derives the column name as #{to}_at - so transitioning to :succeeded stamps succeeded_at.

Multiple State Columns

Every transition takes a column: parameter. This means a model can have multiple independent state machines:

enum :status, { draft: "draft", processing: "processing", completed: "completed" }
enum :payment_status, { unpaid: "unpaid", paid: "paid", refunded: "refunded" }
transition :process, from: :draft, to: :processing, column: :status
transition :mark_paid, from: :unpaid, to: :paid, column: :payment_status

Two state machines on the same model, each tracking a different column. AASM handles this too, but with a more involved aasm :payment_status do block syntax. Here it’s just the column: keyword.

If every transition uses the same column, you can set a default:

state_column :status
transition :process, from: :draft, to: :processing # uses :status

Instrumentation

Every transition publishes an ActiveSupport::Notifications event:

# On success:
"todo.start.success" => { record:, record_id:, from_state: :pending, to_state: :doing, event: :start }
# On invalid state:
"todo.start.invalid" => { record:, from_state: :doing, to_state: :doing, event: :start }
# On callback failure:
"todo.start.failed" => { record:, from_state: :pending, to_state: :doing, event: :start }

Three outcomes, three event names. You can subscribe to any of them with standard Rails instrumentation. We don’t currently subscribe to these - the callback blocks handle their own logging and event emission. But the hooks are there if we need them.

Real Usage

The models that use LiteState show different patterns:

Todo - five transitions with inline business logic. Timestamping, audit events, and cascading unblocks all happen in callback blocks.

PaymentIntent - five transitions where every callback emits a domain event and records a billing audit entry. Each transition has a named timestamp column (pending_at, processing_at, succeeded_at, failed_at).

PayrollRun - five bare transitions with no callbacks. The payroll domain handles side effects in the operations that trigger the transitions, not in the transition itself.

StatutoryRateConfig - the activate transition uses pessimistic locking inside its callback to ensure only one config is active per type:

transition :activate, from: [:inactive, :archived], to: :active, column: :status do
StatutoryRateConfig.where(config_type:, status: :active)
.where.not(id:)
.lock("FOR UPDATE")
.update_all(status: :inactive)
end

The transaction wrapping from LiteState makes this safe. The lock, the deactivation of siblings, and the activation of this record all commit atomically.

Subscription - four transitions with event emission. The cancel transition needs a reason, which is passed through an instance variable set by a wrapper method:

def cancel_with_reason(reason)
@_cancel_reason = reason
cancel
end
transition :cancel, from: [:trialing, :active, :past_due, :suspended],
to: :canceled, column: :status, timestamp: :canceled_at do
update_columns(ended_at: Date.current, cancel_reason: @_cancel_reason)
emit_async :subscription_canceled, self
@_cancel_reason = nil
end

Not elegant. But it works within the constraint that transition defines the method signature - there’s no way to pass arguments into the generated method. The wrapper pattern keeps the extra data close to the call site.

Leave::Request - uses the low-level transition_state API directly instead of the DSL, because the approval and rejection flows need to pass block arguments that vary per caller:

def approve_by!(approver:, comments: nil, acting_for: nil)
transition_state(
to: :approved, allowed_from: :pending,
event: :approve, column: :status, timestamp_field: true
) { Leave::BalanceManager.new(employee).approve_request(self) }
end

This is the escape hatch. The DSL generates convenience methods. The engine underneath is always available when you need more control.

What It Doesn’t Do

No state history. If you need an audit trail of every transition with timestamps and actors, you build it yourself - or use something like Statesman that was designed around that concept. Our models handle this with track_event in callbacks.

No async callbacks. The block runs synchronously inside the transaction. If you need to send an email or enqueue a job after a transition, you call emit_async inside the block and let the event system handle it.

No persistence opinion. It calls update! and transaction - standard ActiveRecord. No custom persistence adapters, no NoSQL support.

No diagram generation. We have whiteboards.

The Whole Thing

LiteState is 277 lines of Ruby. One file. One ActiveSupport::Concern. No dependencies beyond ActiveSupport::Notifications.

The public API is four things:

  • state_column :col - set the default column
  • transition :name, from:, to:, column:, timestamp:, guard:, &block - declare a transition
  • record.can_transition?(:name) - check without executing
  • LiteState::TransitionError - raised on invalid transitions

Each transition is a single declaration that says where it starts, where it ends, and what happens in between. The gem handles the transaction, the validation, the instrumentation, and the error. The model handles the domain logic.

That’s all we needed.