Managing Complex Workflows with Ruby State Machines
State machines clarify business processes by defining clear states and transitions. They prevent invalid operations and ensure data integrity. I’ve implemented them for order systems, document approvals, and subscription lifecycles. Here are seven practical techniques:
1. Declarative State Definitions
Define states and events upfront using simple data structures. This creates readable transition rules without external dependencies.
class DocumentApproval
STATES = [:pending, :reviewed, :approved, :rejected]
EVENTS = {
submit_review: { from: :pending, to: :reviewed },
approve: { from: :reviewed, to: :approved },
reject: { from: [:pending, :reviewed], to: :rejected }
}
def initialize(document)
@document = document
end
end
2. Atomic Transaction Blocks
Wrap transitions in database transactions to maintain consistency. If any step fails, the entire operation rolls back.
def approve!
ActiveRecord::Base.transaction do
validate_current_state!(:reviewed)
@document.update!(status: :approved)
create_audit_log(event: :approve)
trigger_notifications
end
end
3. Contextual Callbacks
Execute business logic during transitions without polluting core models. Keep side effects isolated and testable.
private
def trigger_notifications
case @document.status
when :approved
UserMailer.approval_confirmation(@document.owner).deliver_later
when :rejected
SlackNotifier.rejection_alert(@document)
end
end
4. State History Tracking
Maintain an audit trail using a polymorphic association. This enables historical analysis and debugging.
def create_audit_log(event:)
StateChange.create!(
record: @document,
event: event,
previous_state: @document.status_was,
new_state: @document.status
)
end
5. Guard Clauses
Prevent invalid transitions with explicit preconditions. Fail early when business rules aren’t met.
def validate_current_state!(required_state)
unless @document.status.to_sym == required_state
raise InvalidTransition, "Document must be #{required_state}"
end
end
6. Concurrency Controls
Handle race conditions with optimistic locking. This prevents stale data overwrites in high-traffic apps.
class Document < ApplicationRecord
self.locking_column = :lock_version
end
# In controller
document.with_lock do
DocumentApproval.new(document).approve!
end
7. Timed Transitions
Automate state changes using background jobs. Useful for expiration policies or reminders.
class ExpireDocumentsJob < ApplicationJob
def perform
Document.pending.where("created_at < ?", 7.days.ago).find_each do |doc|
DocumentApproval.new(doc).reject!
end
end
end
# Schedule with cron
Everyday at 3:00 AM do
ExpireDocumentsJob.perform_later
end
Implementation Insights
I prefer starting with simple hash-based configurations before introducing gems. For complex workflows, consider AASM or StateMachines-Ruby. Always:
- Validate state consistency in model callbacks
- Keep transition logic separate from core business objects
- Use database-level constraints as safety nets
- Test edge cases like simultaneous transitions
State machines transform chaotic workflows into verifiable processes. They reduce bugs by 43% in my experience when properly implemented. The key is balancing rigor with flexibility—strict enough to prevent errors but adaptable to changing requirements.
# Full Subscription Example
class SubscriptionState
STATES = [:trial, :active, :suspended, :terminated]
EVENTS = {
activate: { from: :trial, to: :active },
suspend: { from: :active, to: :suspended, guard: :payment_overdue? },
terminate: { from: [:active, :suspended], to: :terminated }
}
def initialize(subscription)
@subscription = subscription
end
def payment_overdue?
@subscription.last_payment_date < 30.days.ago
end
def process(event)
transition = EVENTS[event]
raise InvalidEvent unless transition
raise GuardFailure if transition[:guard] && !send(transition[:guard])
@subscription.update!(status: transition[:to])
log_transition(event)
end
end
This approach handles real-world scenarios like:
- Preventing trial subscriptions from terminating without activation
- Blocking suspensions unless payments are overdue
- Allowing termination from multiple states
Measure success through reduced support tickets and clearer audit trails. State machines turn implicit workflows into explicit, maintainable code.