Table of Contents
Introduction
If you've ever found yourself adding another boolean flag to mark your object's state, chances are you would greatly benefit from introducing the workflow or aasm gems. Those two are best-known and most well-proven state-machine solutions in the Ruby world. I had the chance to work with both of them and found the experience pleasing. Today I'm going to concentrate only on the latter. I encourage you to check both and decide what best suits your needs.
Workflow
Workflow
obviously has a state and it can be in only one state at a time. You can define specific events for the given state which we'll call transitions.
Workflow
allows you to *define final states from which you can't make any further transitions. *Let us consider the following scenario - someone applies for a job. There are surely many steps that the application goes through during the recruitment process but ultimately the candidate can be either accepted
or rejected
. In this case accepted
and rejected
would be our final states.
That's it for starters. I highly recommend checking out the documentation for any additional information.
Usage
I'm not really going to explain how the basic stuff works since it's very well documented in the docs - instead I'll propose an alternative solution for how to make the best of workflow.
The most straightforward solution, the one suggested in the documentation, is to just include workflow in the concerned class, if you're implementing a Rails
app, that would be your model. This approach might seem easy and tempting but it clearly has some drawbacks.
-
Models tend to easily get fat, some of the workflows grow extensively. Anytime our model gets really big it should be a red light that indicates that we're probably doing something wrong. Keep it slim!
-
Model would have to know about many things it shouldn't be concerned about. We could, for one, want to notify the user via email that his application was accepted/rejected. Maybe some of the events can be omitted only by admin user? Sure, we could do all of this in the same class but there's a better way to do this.
Solution
Let's assume that we want to define a very basic workflow for an ordering system. It's simplified and you would definitely want something different in a real app but for our example it'll do just fine.
How we want it to work:
- When an order comes, it's marked as new.
- It can be then canceled or marked as packed. An order can be only canceled by an admin.
- If the order was packed, we want to ship it as a final step.
It seems trivial and it is, but with regular implementation we face a problem. What if
, for example, we wanted to allow employees to manage the orders and display appropriate buttons in an interface. Fair enough. We can use a fact that workflow
, given a current_state
, can return possible events. The problem is we would still return cancel
as a possible event for regular employees. It's not possible to make a condition on the workflow
level that would prevent it. Sure, we could add some if
in the available_events
method, but it feels plain wrong.
Services to the rescue!
But first things first, workflow
included in model:
class Order < ActiveRecord::Base
include Workflow
workflow do
state :new do
event :pack, transition_to: :packed
event :cancel, transition_to: :canceled
end
state :packed do
event :ship, transition_to: :shipped
end
state :canceled
state :shipped
end
def cancel(employee)
halt unless employee.admin?
end
# workflow defines the helper methods that check if an event if possible, for example can_cancel?
def available_events
current_state.events.keys.map(&:to_s).select { |event_name| public_send("can_#{event_name}?") }
end
end
Whenever workflow
encounters halt
it doesn't update the state. Not ideal, is it? The good news is we can do better!
Alternative: move all logic concerning workflow
to the dedicated service leaving our model nice and slim.
class OrderWorkflow
include Workflow
InvalidTransitionError = Class.new(StandardError)
attr_reader :order, :employee
def initialize(order, employee)
@order = order
@employee = employee
end
workflow do
state :new do
event :pack, transition_to: :packed
event :cancel, transition_to: :canceled, if: :admin?.to_proc
end
state :packed do
event :ship, transition_to: :shipped
end
state :shipped
state :canceled
end
# events are being called with transition name and bang, for example order.cancel!
def call(event)
raise InvalidTransitionError unless available_events.include?(event)
public_send(event + "!")
end
# workflow defines helper methods that check if event if possible, for example can_cancel?
def available_events
current_state.events.keys.map(&:to_s).select { |event_name| public_send("can_#{event_name}?") }
end
def admin?
employee.admin?
end
private
# set the new value for state (provided by the Workflow API)
def persist_workflow_state(new_state)
order.update!(workflow_state: new_state)
end
# load and return the current post state (provided by the Workflow API)
def load_workflow_state
order.workflow_state
end
end
This time it behaves just how we wanted, returning different actions for an admin and regular employees.
Additionally, the model is nice and slim, not polluted by logic it should not be concerned about. Profit!