25 April 2017

http://samuelmullen.com/2013/05/the-problem-with-rails-callbacks/

Why Are Callbacks So Problematic?

In his post on ActiveRecord, Caching, and the Single Responsibility Principle, Joshua Clayton noticed “after_* callbacks on Rails models seem to have some of the most tightly-coupled code, especially when it comes to models with associations.”

It’s no coincidence. “before_” callbacks are generally used to prepare an object to be saved. Updating timestamps or incrementing counters on the object are the sort of things we do “before” the object is saved. On the other hand, “after_*” callbacks are primarily used in relation to saving or persisting the object. Once the object is saved, the purpose (i.e. responsibility) of the object has been fulfilled, and so what we usually see are callbacks reaching outside of its area of responsibility, and that’s when we run into problems.

Solving the Problem

Jonathan Wallace, over at the Big Nerd Ranch, ran into to same problems and came up with one simple rule: “Use a callback only when the logic refers to state internal to the object.”

If we can’t use callbacks which extend responsibility outside their class, what do we do? We make an object whose responsibility is to handle that callback.

Before

class Order < ActiveRecord::Base
  belongs_to :user
  has_many :line_items
  has_many :products, :through => :line_items
  
  after_create :purchase_completion_notification
  
  private
  
  def purchase_completion_notification
    Notifier.purchase_notifier(self).deliver
  end
end

class Notifier < ActionMailer...
  def purchase_notifier(order)
    @order = order
    @user = order.user
    @products = order.products

    rest of the action mailer logic
  end
end

After

class Order < ActiveRecord::Base
  belongs_to :user
  has_many :line_items
  has_many :products, :through => :line_items
end

Here’s our new class:

class OrderCompletion
  attr_accessor :order
  
  def initialize(order)
    @order = order
  end
  
  def create
    self.purchase_completion_notification if self.order.save
  end
  
  def purchase_completion_notification
    Notifier.purchase_notifier.deliver(self.order)
  end
end

It’s a simple matter to use this in our controller too:

def create
  @order = Order.new(params[:order])
  @order_completion = OrderCompletion.new(@order)
  
  if @order_completion.create
    redirect_to root_path, notice: 'Your order is being processed.'
  else
    render action: "new"
  end
end