-
Notifications
You must be signed in to change notification settings - Fork 5
Home
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.