Managing state transitions in Ruby on Rails applications requires thoughtful patterns and practices. Here’s a comprehensive exploration of advanced resource state management approaches that I’ve found effective across numerous projects.
State machines serve as the foundation for managing complex state transitions. They provide a structured way to define valid states and the rules for moving between them. In Rails applications, state machines help maintain data integrity and business logic consistency.
The AASM gem offers a robust implementation:
class Order
include AASM
aasm column: 'state' do
state :pending, initial: true
state :processing, :completed, :failed
event :process do
transitions from: :pending, to: :processing
after do
notify_customer
update_inventory
end
end
event :complete do
transitions from: :processing, to: :completed
guard do
sufficient_inventory?
end
end
end
end
Validation during state transitions ensures data consistency. I implement custom validators to check business rules before allowing state changes:
class StateTransitionValidator < ActiveModel::Validator
def validate(record)
return if record.valid_transition?
record.errors.add(:state, "Invalid transition")
end
end
class Order
validates_with StateTransitionValidator, on: :state_change
def valid_transition?
return true if state_was == 'pending' && state == 'processing'
return true if state_was == 'processing' && state == 'completed'
false
end
end
State persistence requires careful consideration of database design. I typically use a dedicated transitions table to track historical state changes:
class CreateStateTransitions < ActiveRecord::Migration[7.0]
def change
create_table :state_transitions do |t|
t.references :resource, polymorphic: true
t.string :from_state
t.string :to_state
t.references :triggered_by
t.text :metadata
t.timestamps
end
end
end
Event callbacks provide hooks for executing business logic during state changes. I structure these to maintain single responsibility:
class Order
include StateMachine
after_transition do |resource, transition|
StateChangeNotifier.new(resource, transition).notify
StateMetricsCollector.new(resource, transition).record
end
before_transition to: :completed do |resource|
resource.completion_date = Time.current
resource.calculate_final_total
end
end
Tracking state history helps with auditing and debugging. I implement a concern that automatically records transitions:
module StateHistoryTracking
extend ActiveSupport::Concern
included do
has_many :state_transitions, as: :resource
after_commit :record_state_transition, on: [:create, :update]
end
private
def record_state_transition
return unless saved_change_to_state?
state_transitions.create!(
from_state: state_before_last_save,
to_state: state,
metadata: transition_metadata
)
end
end
Concurrent state changes require careful handling to prevent race conditions. I implement optimistic locking:
class Order < ApplicationRecord
include Lockable
def transition_state
with_lock do
return false if stale?
yield
save!
end
rescue ActiveRecord::StaleObjectError
errors.add(:base, "State changed by another process")
false
end
end
Error handling during state transitions should be comprehensive and user-friendly:
class StateTransitionError < StandardError; end
class Order
def safe_transition
ApplicationRecord.transaction do
yield
rescue StateTransitionError => e
errors.add(:state, e.message)
raise ActiveRecord::Rollback
rescue => e
Rails.logger.error("Unexpected error during state transition: #{e.message}")
errors.add(:base, "Unable to process state change")
raise ActiveRecord::Rollback
end
end
end
I’ve found that implementing state-specific behaviors through polymorphic objects helps maintain clean code:
module States
class Base
def initialize(resource)
@resource = resource
end
end
class Pending < Base
def allowed_actions
[:process, :cancel]
end
end
class Processing < Base
def allowed_actions
[:complete, :fail]
end
end
end
class Order
def current_state
"States::#{state.classify}".constantize.new(self)
end
end
Handling state-dependent validations requires careful organization:
module StateValidations
extend ActiveSupport::Concern
included do
validate :state_specific_validations
end
private
def state_specific_validations
send("validate_#{state}_state") if respond_to?("validate_#{state}_state", true)
end
def validate_processing_state
errors.add(:base, "Missing required fields") unless processing_requirements_met?
end
end
State machines should integrate well with your application’s authorization system:
class StatePolicy
attr_reader :user, :resource
def initialize(user, resource)
@user = user
@resource = resource
end
def can_transition?(to_state)
return false unless user.present?
case to_state.to_s
when 'published'
user.editor?
when 'archived'
user.admin?
else
false
end
end
end
For complex workflows, I implement state orchestration services:
class StateOrchestrator
def initialize(resource)
@resource = resource
@transitions = []
end
def process
ApplicationRecord.transaction do
execute_transitions
notify_subscribers
update_related_records
end
end
private
def execute_transitions
@transitions.each do |transition|
@resource.send("#{transition}!")
end
end
end
Testing state transitions requires comprehensive coverage:
RSpec.describe Order do
describe "state transitions" do
let(:order) { create(:order, state: 'pending') }
context "when processing" do
it "transitions to processing state" do
expect { order.process! }.to change { order.state }
.from('pending').to('processing')
end
it "prevents invalid transitions" do
order.state = 'completed'
expect { order.process! }.to raise_error(AASM::InvalidTransition)
end
end
end
end
These patterns have served me well in creating maintainable and reliable state management systems. The key is finding the right balance between flexibility and complexity while ensuring your state management solution aligns with your application’s needs.