ActiveState

A tiny gem for easily using the state design pattern with ActiveRecord models. State objects will be automatically created when the model is instantiated. States can use the ActiveModel::Validations DSL for creating validations, which will be run when the model is validated. Scopes for individual states can easily be created on the model.

Installation

Add this line to your application's Gemfile:

gem 'active_state'

And then execute:

$ bundle

Or install it yourself as:

$ gem install active_state

Usage

Let's assume that we have a model called Report. Every Report has an author - a User. Reports also have a url leading to an attached file (this example simply uses a string).

Reports are approved or rejected by a User. If a Report is rejected, the reviewer has to provide a reason for rejecting it. Reports cannot be reviewed by the User who created them.

A Report can therefore be in one of three states - Pending, Approved or Rejected. In every state, different validations have to be run and methods will behave differently. Instead of polluting the model with many conditionals, the state pattern can be used to split the code into multiple short and readable classes.

The migration

class CreateReports < ActiveRecord::Migration[6.0]
  def change
    create_table :reports do |t|
      t.references :author, foreign_key: { to_table: :users }
      t.references :reviewer, foreign_key: { to_table: :users }
      t.string :file_url
      t.datetime :reviewed_at
      t.text :reason_for_rejection
      t.string :state_name # This is for ActiveState

      t.timestamps
    end
  end
end

The model

class Report < ApplicationRecord
  include ActiveState::Model
  self.initial_state = Pending

  belongs_to :author, class_name: 'User'
  belongs_to :reviewer, class_name: 'User', optional: true

  # a url has to be present in all states
  validates_presence_of :file_url

  # create scopes for different states
  # you can now call Report.pending and Report.approved
  # custom names for scopes are also possible
  # Report.thrown_out_the_window will return reports in the Rejected state
  scope_for_state Pending, Approved, thrown_out_the_window: Rejected

  # delegate methods that will be implemented in the state objects
  delegate :approve_by, :reject_by, to: :state
end

The states

The states should probably be put inside a namespace to avoid naming collisions. In this case, they are put inside the Report class, but that is not required for ActiveState to work. To satisfy the Rails autoloading mechanism, these files need to be put in app/*/reports. It is up to you if you put the reports directory into app/models or into a new directory in app (possibly something like app/states).

Pending:

class Report
  class Pending < ActiveState::Base
    def approve_by(user)
      review_by user
      context.state = Approved.new context
    end

    def reject_by(user, reason:)
      review_by user
      context.reason_for_rejection = reason
      context.state = Rejected.new context
    end

    private

    def review_by(user)
      context.reviewer = user
      context.reviewed_at = Time.now
    end
  end
end

Because some validations and implementations of methods will be the same in both Approved and Rejected states, we can make a common superclass for both. This is done simply to show that such a thing is possible, but it is up to us to make sure that the Reviewed state will never actually be assigned to a model. Treat it like an abstract class.

class Report
  class Reviewed < ActiveState::Base
    # For validating attributes of the model, we need to get access to them
    delegate :reviewer, :reviewed_at, to: :context
    validates_presence_of :reviewer, :reviewed_at

    def approve_by(_user)
      raise StandardError, 'Report already reviewed'
    end

    def approve_by(_user, _reason)
      raise StandardError, 'Report already reviewed'
    end
  end
end

The Approved state does not need to do anything more than the Reviewed state. However, it would not make sense to have the state representing rejection subclass a state representing approval. This is why creating an empty subclass might be justifiable in this situation.

class Report
  class Approved < Reviewed
  end
end

Rejected:

class Report
  class Rejected < Reviewed
    delegate :reason_for_rejection, to: :context
    validates_presence_of :reason_for_rejection
  end
end

License

The gem is available as open source under the terms of the MIT License.