Decoupling Models and Workflow

Kacper Pucek

Decoupling_models_and_workflow-(1)

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.

  1. 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!

  2. 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:

  1. When an order comes, it's marked as new.
  2. It can be then canceled or marked as packed. An order can be only canceled by an admin.
  3. 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!

Kacper Pucek avatar
Kacper Pucek