A revolutionary two-layer hierarchical state system for Ruby models. Perfect for complex workflows that are too complex for flat state machines but don't need full orchestration engines.
hybrid-state-model introduces a two-layer state system:
- Primary State β high-level lifecycle (e.g.,
pending,active,shipped,delivered) - Secondary Micro-State β small step within the primary state (e.g.,
verifying_email,awaiting_payment,in_transit,out_for_delivery)
This creates a simple but powerful hierarchical state system that reduces complexity instead of adding it.
- π― Two-layer state system β Primary states with nested micro-states
- π Automatic constraints β Micro-states are validated against their primary state
- π Flexible transitions β
promote!,advance!,reset_micro!, andtransition! - π Querying capabilities β
in_primary,in_micro,with_primary_and_microscopes - π Metrics tracking β Track time spent in each state (optional)
- ποΈ Callbacks β
before_primary_transition,after_primary_transition, etc. - β ActiveRecord integration β Works seamlessly with Rails models
Add this line to your application's Gemfile:
gem 'hybrid-state-model'And then execute:
$ bundle installOr install it yourself as:
$ gem install hybrid-state-modelclass CreateOrders < ActiveRecord::Migration[7.0]
def change
create_table :orders do |t|
t.string :status # Primary state
t.string :sub_status # Micro state
t.text :state_metrics # Optional: for metrics tracking
t.timestamps
end
end
endclass Order < ActiveRecord::Base
include HybridStateModel
hybrid_state do
# Define primary state field and possible values
primary :status, %i[pending processing shipped delivered returned]
# Define micro state field and possible values
micro :sub_status, %i[
awaiting_payment
fraud_check_passed
fraud_check_failed
ready_to_pack
packing
assigning_carrier
waiting_for_pickup
in_transit
out_for_delivery
inspection
return_processing
return_complete
]
# Map which micro-states are allowed for each primary state
map status: :pending, sub_status: %i[awaiting_payment]
map status: :processing, sub_status: %i[fraud_check_passed fraud_check_failed ready_to_pack packing assigning_carrier]
map status: :shipped, sub_status: %i[waiting_for_pickup in_transit out_for_delivery]
map status: :returned, sub_status: %i[inspection return_processing return_complete]
# Optional: Reset micro state when primary state changes
when_primary_changes reset_micro: true
# Optional: Callbacks
before_primary_transition :shipped do
raise "Cannot ship without payment" unless paid?
end
after_primary_transition :delivered do
send_delivery_confirmation_email
end
end
end# Create an order
order = Order.create!(status: :pending, sub_status: :awaiting_payment)
# Promote to primary state (moves to next major state)
order.promote!(:processing)
# => status: :processing, sub_status: nil (reset because of when_primary_changes)
# Advance micro state (moves within current primary state)
order.advance!(:ready_to_pack)
# => status: :processing, sub_status: :ready_to_pack
# Transition both at once
order.transition!(primary: :shipped, micro: :waiting_for_pickup)
# => status: :shipped, sub_status: :waiting_for_pickup
# Advance through micro states
order.advance!(:in_transit)
order.advance!(:out_for_delivery)
# Promote to final state
order.promote!(:delivered)
# Querying
Order.in_primary(:shipped)
Order.in_micro(:in_transit)
Order.with_primary_and_micro(primary: :shipped, micro: :out_for_delivery)
Order.with_micro # Orders that have a micro state
Order.without_micro # Orders without a micro state
# Validation
order.status = :delivered
order.sub_status = :assigning_carrier # β Invalid! Will fail validationDefines the primary state field and its possible values.
primary :status, %i[pending active inactive]Defines the micro state field and its possible values.
micro :sub_status, %i[verifying_email awaiting_approval]Maps which micro-states are allowed for a specific primary state.
map status: :active, sub_status: %i[verifying_email awaiting_approval]Automatically resets the micro state when the primary state changes.
Runs a callback before transitioning to the specified primary state(s).
before_primary_transition :shipped do
validate_shipping_address
endRuns a callback after transitioning to the specified primary state(s).
Runs a callback before transitioning to the specified micro state(s).
Runs a callback after transitioning to the specified micro state(s).
Transitions to a new primary state. Automatically resets micro state if configured.
order.promote!(:shipped)
order.promote!(:delivered, skip_save: true) # Don't save immediatelyTransitions to a new micro state within the current primary state.
order.advance!(:in_transit)Resets the micro state to nil.
order.reset_micro!Transitions both primary and micro states at once.
order.transition!(primary: :shipped, micro: :waiting_for_pickup)Checks if the record can transition to the specified primary state.
Checks if the record can transition to the specified micro state.
Finds records with the specified primary state(s).
Order.in_primary(:shipped, :delivered)Finds records with the specified micro state(s).
Order.in_micro(:in_transit, :out_for_delivery)Finds records with both the specified primary and micro states.
Order.with_primary_and_micro(primary: :shipped, micro: :in_transit)Finds records that have a micro state set.
Finds records that don't have a micro state set.
If you add a state_metrics text/json column to your table, the gem will automatically track time spent in each state:
# Migration
add_column :orders, :state_metrics, :text
# Usage
order.state_metrics
# => {
# "pending" => {"entered_at" => "...", "duration" => 120.5},
# "processing" => {"entered_at" => "...", "duration" => 300.0},
# "processing:ready_to_pack" => {"entered_at" => "...", "duration" => 60.0}
# }
order.time_in_primary_state(:processing)
# => 300.0 (seconds)
order.current_state_duration
# => 45.2 (seconds in current state)primary :status, %i[pending processing shipped delivered]
micro :sub_status, %i[ready_to_pack packing waiting_for_pickup in_transit out_for_delivery]primary :status, %i[active suspended canceled]
micro :sub_status, %i[verifying_card awaiting_payment retrying_charge]primary :status, %i[active pending]
micro :sub_status, %i[verifying_email uploading_documents awaiting_approval]Bug reports and pull requests are welcome on GitHub at https://github.com/afshmini/hybrid-state-model.
The gem is available as open source under the terms of the MIT License.