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 endendThat’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) endendFive 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 # ...endThis generates todo.start - a method that calls the internal transition_state engine. The engine does five things in order:
- Read the current state from the column
- Check it’s in the
allowed_fromlist - Run the guard (if one exists)
- Open a transaction, update the column, run the callback block
- Publish an
ActiveSupport::Notificationsevent
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_reasonendThe 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 buttonendIt 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, selfendtimestamp: :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: :statustransition :mark_paid, from: :unpaid, to: :paid, column: :payment_statusTwo 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 :statusInstrumentation
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)endThe 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 cancelend
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 = nilendNot 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) }endThis 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 columntransition :name, from:, to:, column:, timestamp:, guard:, &block- declare a transitionrecord.can_transition?(:name)- check without executingLiteState::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.