Do you need to build a State Machine at least once in your career?

Do you need to build a State Machine at least once in your career?
Do you need to build a State Machine at least once in your career?

May 5, 2026

Probably not.

Tokyo Topographic Map
Built for Ruby on Rails

Build Maps Without
Google APIs

Generate beautiful production-ready maps directly from your Rails backend. Fast rendering, zero external dependencies, full control.

✓ No API fees ✓ Self-hosted ✓ Rails Native ✓ Fast Rendering
Why developers switch
Replace expensive map stacks.

Stop relying on third-party map billing and bloated JS libraries. Render static or dynamic maps directly in Ruby.

Try It Now
Tokyo MapView Demo

But sooner or later, you’ll debug one.

Orders that won’t transition. Jobs stuck in weird states. Workflows that “shouldn’t happen”… but do.

That’s when it clicks:

You’ve been using state machines all along just without calling them that.


The hidden complexity

Article content

At first, it looks simple:

order.status = :paid

But then reality kicks in:

  • Can you go from :pending → :shipped directly?
  • What if payment failed midway?
  • Can a cancelled order be shipped?
  • What happens under concurrency?

Suddenly, your “simple status field” becomes a set of rules.

That’s a state machine.


The usual mistakes

Most ad-hoc implementations fail in subtle ways:

1. Invalid transitions

order.status = :shipped # from :pending 😬

No constraints → inconsistent state.


2. Ignoring conditions (guards)

ship_order(order)

…but:

  • payment not confirmed
  • warehouse not ready

Now you have invalid reality modeled as valid state.


3. Hidden transitions

Article content

Business logic scattered across:

  • controllers
  • jobs
  • services

No single source of truth.


4. Race conditions

Two processes:

  • one cancels the order
  • another ships it

👉 Welcome to non-deterministic chaos.


A better mental model

A state machine forces you to define:

  • States → :pending, :paid, :shipped
  • Events → :pay, :ship, :cancel
  • Transitions → what is allowed
  • Guards → when it’s allowed

Example:

transition :ship,
from: :paid,
to: :shipped,
guard: ->(ctx) { ctx[:warehouse_confirmed] }

Now your system is:

  • explicit
  • constrained
  • predictable

Why this matters (more than you think)

Article content

State machines show up everywhere:

  • payment systems
  • background job pipelines
  • onboarding flows
  • API integrations
  • IoT devices (especially fun here)

You don’t always build one.

But you’re almost always living inside one.


The real takeaway

You don’t need to ship your own state machine library.

But you should understand:

  • why invalid transitions happen
  • how guards protect invariants
  • where race conditions break workflows

Because once systems grow…

State is no longer a variable. It’s a contract.


A small challenge

Try this sometime:

Build a minimal state machine in Ruby.

Not for production.

Just to feel the constraints.

You’ll start noticing patterns you’ve been missing for years.

# =============================================================================
# State Machine Implementation in Ruby
# =============================================================================
class StateMachine
class InvalidTransitionError < StandardError; end
class StateNotFoundError < StandardError; end
attr_reader :current_state
def initialize(initial_state)
@current_state = initial_state
@states = {}
@transitions = {}
@callbacks = { on_enter: {}, on_exit: {}, on_transition: [] }
end
# ── DSL ──────────────────────────────────────────────────────────────────
def add_state(name, &block)
@states[name] = block
self
end
# transition :event_name, from: :state_a, to: :state_b
# transition :event_name, from: [:state_a, :state_b], to: :state_c
def add_transition(event, from:, to:, guard: nil)
Array(from).each do |source|
@transitions ||= {}
@transitions[event] = { to: to, guard: guard }
end
self
end
def on_enter(state, &block)
@callbacks[:on_enter][state] = block
self
end
def on_exit(state, &block)
@callbacks[:on_exit][state] = block
self
end
def on_transition(&block)
@callbacks[:on_transition] << block
self
end
# ── Trigger ───────────────────────────────────────────────────────────────
def trigger(event, **context)
transition = available_transition(event)
raise InvalidTransitionError,
"No transition '#{event}' from state '#{@current_state}'" unless transition
if transition[:guard] && !transition[:guard].call(context)
raise InvalidTransitionError,
"Guard prevented transition '#{event}' from '#{@current_state}'"
end
previous = @current_state
target = transition[:to]
fire_callback(:on_exit, previous, previous, target, context)
@current_state = target
fire_callback(:on_enter, target, previous, target, context)
@callbacks[:on_transition].each { |cb| cb.call(previous, target, event, context) }
self
end
# ── Queries ───────────────────────────────────────────────────────────────
def can?(event)
available_transition(event) != nil
end
def in_state?(state)
@current_state == state
end
def available_events
(@transitions[@current_state] || {}).keys
end
def states
@states.keys
end
def to_s
"#<StateMachine current=#{@current_state} available_events=#{available_events}>"
end
private
def available_transition(event)
(@transitions[@current_state] || {})[event]
end
def fire_callback(type, state, *args)
cb = @callbacks[type][state]
cb&.call(*args)
end
end
# =============================================================================
# Builder DSL — lets you define a machine declaratively
# =============================================================================
class StateMachineBuilder
def self.build(initial_state, &block)
machine = StateMachine.new(initial_state)
builder = new(machine)
builder.instance_eval(&block)
machine
end
def initialize(machine)
@machine = machine
end
def state(name, &block) = @machine.add_state(name, &block)
def transition(event, **opts) = @machine.add_transition(event, **opts)
def on_enter(state, &block) = @machine.on_enter(state, &block)
def on_exit(state, &block) = @machine.on_exit(state, &block)
def on_transition(&block) = @machine.on_transition(&block)
end
# =============================================================================
# Example — Traffic Light
# =============================================================================
puts "=" * 60
puts "Traffic Light State Machine"
puts "=" * 60
traffic_light = StateMachineBuilder.build(:red) do
state :red
state :green
state :yellow
transition :go, from: :red, to: :green
transition :slow, from: :green, to: :yellow
transition :stop, from: :yellow, to: :red
on_enter(:green) { puts " → Light is GREEN — go!" }
on_enter(:yellow) { puts " → Light is YELLOW — slow down!" }
on_enter(:red) { puts " → Light is RED — stop!" }
on_transition do |from, to, event, _ctx|
puts " [transition] #{from} --#{event}--> #{to}"
end
end
puts "\nInitial state: #{traffic_light.current_state}"
puts "Available events: #{traffic_light.available_events}\n\n"
traffic_light.trigger(:go)
traffic_light.trigger(:slow)
traffic_light.trigger(:stop)
puts "\nFinal state: #{traffic_light.current_state}"
# Invalid transition
begin
traffic_light.trigger(:slow) # can't slow from red
rescue StateMachine::InvalidTransitionError => e
puts "\nCaught error: #{e.message}"
end
# =============================================================================
# Example — Order Workflow (with guards)
# =============================================================================
puts "\n" + "=" * 60
puts "Order Workflow State Machine"
puts "=" * 60
order = StateMachineBuilder.build(:pending) do
state :pending
state :paid
state :shipped
state :delivered
state :cancelled
transition :pay, from: :pending, to: :paid
transition :ship, from: :paid, to: :shipped,
guard: ->(ctx) { ctx[:warehouse_confirmed] }
transition :deliver, from: :shipped, to: :delivered
transition :cancel, from: [:pending, :paid], to: :cancelled
on_enter(:paid) { puts " → Payment received. Thank you!" }
on_enter(:shipped) { puts " → Order is on its way!" }
on_enter(:delivered) { puts " → Order delivered. Enjoy!" }
on_enter(:cancelled) { puts " → Order cancelled." }
end
puts "\nInitial: #{order.current_state}"
puts "Can pay? #{order.can?(:pay)}"
puts "Can ship? #{order.can?(:ship)}"
order.trigger(:pay)
puts "\nTrying to ship without warehouse confirmation..."
begin
order.trigger(:ship, warehouse_confirmed: false)
rescue StateMachine::InvalidTransitionError => e
puts " Blocked: #{e.message}"
end
puts "\nShipping with warehouse confirmation..."
order.trigger(:ship, warehouse_confirmed: true)
order.trigger(:deliver)
puts "\nFinal state: #{order.current_state}"
puts "Available events: #{order.available_events}"

Article content

Leave a comment