Where should RoR business logic be kept? – Fat models, thin controllers – Concerns



Where should RoR business logic be kept? – Fat models, thin controllers – Concerns

0 0


ror_business_logic_presentation

A short presentation on different ways to organize business logic in Ruby on Rail projects

On Github radanskoric / ror_business_logic_presentation

Where should RoR business logic be kept?

Radan Skorić

Business logic that changes the state of the system

Why bother?

Business logic is often the fastest changing code in the system

Fat models, thin controllers

  • Put all the business logic into models, as special methods
  • Controllers do nothing but invoke methods on the model
  class JobsController < ApplicationController
    #...
    def create
      @job = current_user.post_job(job_params)
      if @job.persisted?
        redirect_to jobs_path,
        	notice: 'Job was successfully created.'
      else
        render :new
      end
    end

    def apply
      if current_user.apply_for_job(@job)
        redirect_to jobs_path,
        	notice: 'You successfully applied to the job'
      else
        redirect_to jobs_path,
        	alert: "You don't have the necessary skills"
      end
    end
    #...
  end
  				 	
  class User < ActiveRecord::Base
    has_many :jobs
    has_many :user_skills
    has_many :skills, through: :user_skills

    def post_job(job_params)
      job_params[:skills] &&= Skill.where(id: job_params.delete(:skills)).load
      jobs.create(job_params).tap do |job|
        if job.persisted?
          #Notify users with matching skills
        end
      end
    end

    def apply_for_job(job)
      return false if (self.skills & job.skills).size != job.skills.size

      #Notify job owner about application
      true
    end
  end
  				 	

Pros

  • Nice rails-style interface for method calls, e.g. job.apply
  • Only one place to look at for all the rules related to a certain model

Cons

  • Can't manipulate the model directly using native rails methods
  • Controllers are not unified, each calls custom business method
  • Without any rule for splitting business rules into other classes, the models source will grow without bound

Concerns

  • Put the logic into the models but through mixins, i.e. concerns
  • Each concern contains a group of related methods
module JobPoster
  def post_job(job_params)
    job_params[:skills] &&= Skill.where(id: job_params.delete(:skills)).load
    jobs.create(job_params).tap do |job|
      if job.persisted?
        #Notify users with matching skills
      end
    end
  end
end

module JobApplicant
  def apply_for_job(job)
    return false if (self.skills & job.skills).size != job.skills.size

    #Notify job owner about application
    true
  end
end
            
  class User < ActiveRecord::Base
    has_many :jobs
    has_many :user_skills
    has_many :skills, through: :user_skills

    include JopPoster
    include JobApplicant
  end
            

Pros

  • Allows for cleaner files (although not classes), each responsible for its own piece of logic
  • Keeps rails-style interface for method calls

Cons

  • Easy to fall into bad practice of using methods that don't belong to particular concern, but still accessible (since they are all in the same class), so everyone in the team should be extremely cautious about this
  • Team members have to be careful about using unique method names to avoid method shadowing in the models

Fat models with callbacks

  • Design the models so that all business actions are mapped to direct manipulations of the models
  • Side effects and updating of related objects are handled through callbacks
class Job < ActiveRecord::Base
  ...
  after_create :notify_users_with_matching_skills
  ...
end

class JobApplication < ActiveRecord::Base
  ...
  validate :user_has_necessary_skills
  ...
  after_create :notify_job_owner
  ...
end
            

Pros

  • You are free to use any low-level rails methods to update model attributes and still have consistent data

Cons

  • Logic is non linear and it is hard to track what happens when you do an update
  • For the same reason hard to handle bugs and extend code

Fat controllers

  • Put the logic into controllers
  • Each controller action is responsible for a clear business action
  class JobsController < ApplicationController
    #...
    def create
      job_params[:skills] &&=
        Skill.where(id: job_params.delete(:skills)).load
      @job = current_user.jobs.create(job_params)
      if @job.persisted?
        #Notify users with matching skills
        redirect_to jobs_path,
          notice: 'Job was successfully created.'
      else
        render :new
      end
    end

    def apply
      if (current_user.skills & job.skills).size == job.skills.size
        #Notify job owner about application
        redirect_to jobs_path,
          notice: 'You successfully applied to the job'
      else
        redirect_to jobs_path,
          alert: "You don't have the necessary skills"
      end
    end
  end
            
  class User < ActiveRecord::Base
    has_many :jobs
    has_many :user_skills
    has_many :skills,
      through: :user_skills
  end
            

Pros

  • Simplicity, easy to start

Cons

  • Coupled with rails, as a result hard to test and reuse.
  • Can not have business rules invoking other business rules since controllers can't call other controllers without resorting to ugly hacks

Form objects

  • Create a plain ruby class and mixin some of the ActiveModel behaviour
  • Use it as a regular model with the main difference being that it won't be persisted
  • Place the business logic into its validations and "save" methods
            
#app/forms/job_application_form.rb
class JobApplicationForm
  include ActiveModel::Model

  attr_accessor :user, :job

  validates :user_has_necessary_skills

  def save
    # Notify job owner
    # ...
    # Create/Update real models as needed
  end

private

  def user_has_necessary_skills
    (@user.skills & @job.skills).size != @job.skills.size
  end

end

            
            
            
#app/controller/jobs_controller.rb
def apply
  @form = JobApplicationForm.new(job_application_form_params).save
  if @form.persisted?
    redirect_to jobs_path, notice: 'Job was successfully created.'
  else
    render :new
  end
end

#app/models/job.rb
class Job < ActiveRecord::Base
  belongs_to :user
  has_many :job_skills
  has_many :skills, through: :job_skills

  validates_presence_of :description
end
            

Pros

  • Can use rails form builders with any custom attributes
  • The controllers and views can be implemented in standard ways since they can pretend that they are dealing with normal models

Cons

  • Separate class for each action
  • Logic still a bit unlinear
  • Becomes akward when logic is not easily maped to form submissions

Service objects

  • Introduce special objects to contain the business logic
  • Business actions are implemented as methods on the objects
  • Side effects and validations are explicitly stated in the methods
            
#app/controller/jobs_controller.rb
def create
  @job = JobPostingService.new(current_user).post(job_params)
  if @job.persisted?
    redirect_to jobs_path, notice: 'Job was successfully created.'
  else
    render :new
  end
end

def apply
  if JobApplicationService.new(current_user, @job).apply
    redirect_to jobs_path, notice: 'You successfully applied to the job'
  else
    redirect_to jobs_path, alert: "You don't have the necessary skills"
  end
end
            
            
#app/models/user.rb
class User < ActiveRecord::Base
  has_many :jobs
  has_many :user_skills
  has_many :skills, through: :user_skills
end

#app/models/job.rb
class Job < ActiveRecord::Base
  belongs_to :user
  has_many :job_skills
  has_many :skills, through: :job_skills

  validates_presence_of :description
end
            
            
            
#app/managers/base_service.rb
class BaseService
  def initialize(performer)
    @performer = performer
  end
end

#app/managers/job_posting_service.rb
class JobPostingService < BaseService
  def post(job_params)
    job_params[:skills] &&= Skill.where(id: job_params.delete(:skills)).load
    @performer.jobs.create(job_params).tap do |job|
      if job.persisted?
        #Notify users with matching skills
      end
    end
  end
end
            
            
#app/managers/job_application_service.rb
class JobApplicationService < BaseService
  def initialize(performer, job)
    @job = job
    super(performer)
  end

  def apply
    return false if (@performer.skills & @job.skills).size != @job.skills.size

    #Notify job owner about application
    true
  end
end
            
            

Pros

  • Minimum "magic", code linear and easy to follow
  • Easy to have business action invoking other business actions

Cons

  • Can't use form builders (without hybrid approach)
  • Tends to cause somewhat big procedural style methods

Data, Context, Interaction

  • Models are thin, only contain data, relationships and invariants
  • We introduce contexts to "JIT" bind the roles to models
  • The roles are responsible for implementation of interactions between models

The code:

            
class JobsController < ApplicationController
  #...
  def create
    @job = JobCreationContext.new(current_user, job_params).post_new_job
    if @job.persisted?
      redirect_to jobs_path,
        notice: 'Job was successfully created.'
    else
      render :new
    end
  end

  def apply
    if EmploymentNegotiationContext.new(current_user, @job).apply
      redirect_to jobs_path,
        notice: 'You successfully applied to the job'
    else
      redirect_to jobs_path,
        alert: "You don't have the necessary skills"
    end
  end
  #...
end
          
          
#app/models/user.rb
class User < ActiveRecord::Base
  has_many :jobs
  has_many :user_skills
  has_many :skills, through: :user_skills
end

#app/models/job.rb
class Job < ActiveRecord::Base
  belongs_to :user
  has_many :job_skills
  has_many :skills, through: :job_skills

  validates_presence_of :description
end
            
            
            
#app/contexts/post_job_context.rb
class JobCreationContext
  def initialize(user, job_params)
    @user, @job_params = user, job_params
    @user.extend Employer
  end

  def post_new_job
    @user.post_job(@job_params)
  end
end
#app/contexts/apply_to_job_context.rb
class EmploymentNegotiationContext
  def initialize(user, job)
    @user, @job = user, job
    @user.extend Developer
  end

  def apply
    @user.apply_for_job(@job)
  end
end
            
            
#app/roles/employer.rb
module Employer
  def post_job(job_params)
    job_params[:skills] &&= Skill.where(id: job_params.delete(:skills)).load
    jobs.create(job_params).tap do |job|
      if job.persisted?
        #Notify users with matching skills
      end
    end
  end
end
#app/roles/developer.rb
module Developer
  def apply_for_job(job)
    return false if (self.skills & job.skills).size != job.skills.size

    #Notify job owner about application
    true
  end
end
            
            

Pros

  • More oop-style than services and methods are leaner
  • Provides additional logic only when needed

Cons

  • Generates a lot of classes, i.e. a lot of boilerplate code
  • The usual ruby implementation has no way of removing a role from a model once context finishes execution

Questions?

For reference, the approaches:

  • Fat models, thin controllers
  • Concerns
  • Callbacks
  • Fat controllers
  • Form objects
  • Service objects
  • Data, Context, Interaction