Skip to content
Jason Cheong-Kee-You edited this page Oct 5, 2015 · 3 revisions

The Pitch

Here I'm going to explain why Vorpal was created. I work with a monolithic Rails app. I'd like to show you a class that illustrates the heart of the problem:

class Job < ActiveRecord::Base
  belongs_to :project
  belongs_to :line

  has_many :time_reports
  has_many :breaks
  has_many :productions
  has_many :subcomponent_consumptions
  has_many :rejected_items

  validates_presence_of :project, :line, :scheduled_end_at, :scheduled_start_at, :site_id
  validates_numericality_of :units_expected, :less_than => 100000000000, :greater_than_or_equal_to => 0
  validate :validate_do_not_reconcile, :on => :update

  before_validation :set_site_id, :on => :create
  before_validation :set_do_not_reconcile, :on => :update
  before_validation :restore_reconciliation_status_for_invalid_transition, :on => :update
  before_create :create_wip_pallet
  before_destroy :destroyable?

  # Repository concern
  def self.search(site, page, query_string)
  def self.paging(page, options = {})

  # Validation
  def enforce_top_up_rules(options)
  def handle_production_strategy_errors(options)
  def handle_no_labor_errors
  def prevent_line_change
  def validate_job

  # Construction concern
  def create_wip_pallet
  def seed_units_expected

  # Model state
  def started?
  def start!
  def stopped?
  def stop!
  def resume!
  def destroyable?(options = {})
  def deletable?
  def editable?
  def on_break?
  def active_breaks
  def has_time_records?(now)

  # Business logic
  def add_production_with_substitutions(finished_good_quantity, consumption_strategy, lot_code, expiry_date, options)

  # Etc.
  ...
end

The above code snippet is taken from an actual class in my project that is over 1300 lines. Let's consider this class. Notice that there are many different responsibilities in this class. We're mixing context-specific validations, context-independent validations, database access, business logic, and construction concerns.

Let's consider what the code might look like if we were to separate a lot of these concerns out of the model. Here's one possibility:

class Job < ActiveRecord::Base
  belongs_to :project
  belongs_to :line

  has_many :time_reports
  has_many :breaks
  has_many :productions
  has_many :subcomponent_consumptions
  has_many :rejected_items

  validates :project, :line, :scheduled_end_at, :scheduled_start_at, :site_id, presence: true
  validates :units_expected, numericality: {:less_than => 100000000000, :greater_than_or_equal_to => 0}

  def started?
  def start!
  def stopped?
  def stop!
  def resume!
  def on_break?
  def active_breaks
  def has_time_records?(now)
end

class JobRepository
  def self.search(site, page, query_string)
  def self.paging(page, options = {})
end

class JobProductionValidator
  def self.validate(job)
end

class JobUpdateValidator
  def self.validate(job)
end

class JobFactory
  def self.create_job
end

class JobDestroyer
  def self.destroyable?(job, options = {})
end

class AddProductionService
  def self.add_production_with_substitutions(finished_good_quantity, consumption_strategy, lot_code, expiry_date, options)
end

What did we do? We extracted a lot of services out of the model. The model is almost anemic in-terms of services. It's simply a data holder plus validations, and some simple properties and methods for switching states. All database access, construction concerns, and context-specific concerns have been removed. Where I work, we consider this good organization of responsibilities. Could we just stop here? Because, the above refactoring involves no new libraries. We're still using ActiveRecord. This approach has the benefit of introducing no new tools in the development stack.

I heartily recommend that if your team is disciplined enough and has a strong design sense then you could stop here. We've tried this. Our experience is that the sheer weight of the gravitational pull of the ActiveRecord API results in the model class pulling in more responsibilities. Vorpal is our experiment at breaking ActiveRecord from the models. Thus completely removing the gravitational weight from the models. So let's look at what the code might look with Vorpal:

class Job
  include Virtus.model
  include ActiveModel::Validations

  attribute :project, Project
  attribute :line, Line

  attribute :time_reports, Array[TimeReport]
  attribute :breaks, Array[Break]
  attribute :productions, Array[Production]
  attribute :subcomponent_consumptions, Array[SubcomponentConsumption]
  attribute :rejected_items, Array[RejectedItem]

  validates :project, :line, :scheduled_end_at, :scheduled_start_at, :site_id, presence: true
  validates :units_expected, numericality: {:less_than => 100000000000, :greater_than_or_equal_to => 0}

  def started?
  def start!
  def stopped?
  def stop!
  def resume!
  def on_break?
  def active_breaks
  def has_time_records?(now)
end

class JobRepository
  def self.search(site, page, query_string)
  def self.paging(page, options = {})
end

class JobProductionValidator
  def self.validate(job)
end

class JobUpdateValidator
  def self.validate(job)
end

class JobFactory
  def self.create_job
end

class JobDestroyer
  def self.destroyable?(job, options = {})
end

class AddProductionService
  def self.add_production_with_substitutions(finished_good_quantity, consumption_strategy, lot_code, expiry_date, options)
end

Not much of a change. However, there's no way to do Job.all, or self.save from a Job instance. This is dramatic. Instead of Job pulling in responsibilities. It's quite the opposite. It's simply not possible to use Job as a swiss-army knife. It's much harder to give it context-specific responsibilities. And since the responsibilities don't naturally sit there, they find a different home.

Clone this wiki locally