Callback chain management with attribute accessors

November 22, 2016 | 5 Minute Read

ActiveRecord callbacks like after_create, after_update etc can be used to abstract hooks related to creating or updating different objects that are logically affected by the object being saved. However being able to control a trigger inside a callback when deciding to stop or continue firing callbacks is important in some cases too.

Let's continue with our example domain of vehicles and wheels. A vehicle is an object that has many wheels. A vehicle is also an abstract class where automobiles, bicycles etc inherit.

class Vehicle < ActiveRecord::Base
  has_many :wheels, dependent: :destroy
  after_create :create_wheel

  def count_wheels
    wheels.count
  end

  private

  def create_wheel
    self.wheels.create(diameter: 30)
  end

end
class Bicycle < Vehicle
  def wheel_limit
    2
  end
end
class Wheel < ActiveRecord::Base
  belongs_to :vehicle

  after_create :create_other_wheel

  private

  def create_other_wheel
    self.vehicle.wheels.create(diameter: self.diameter)
  end
end

When a vehicle is created a default wheel is created through an after_create callback. No problem there. But if we imagine that we would like to also add another wheel on each wheel creation, this will trigger a chain of callbacks which we will surely like to finalize at some point.

Bicycle.create age: 2

At this point we get an error (obviously):

 SystemStackError: stack level too deep 

One solution to this is to use a skip_callback statement just after the after_create statement in the wheel model:

class Wheel < ActiveRecord::Base
  belongs_to :vehicle

  after_create :create_other_wheel
  skip_callback :create, :after, :create_other_wheel, if: -> { self.vehicle.wheels.count >= self.vehicle.wheel_limit }

  private

  def create_other_wheel
    self.vehicle.wheels.create(diameter: self.diameter)
  end
end
Bicycle.create(age: 2).wheels.count
=> 2

This is not very DRY and personally I think it can be made better. Another solution is to explicitly turn off the callback trigger inside the callback, halting the chain:

class Wheel < ActiveRecord::Base
  belongs_to :vehicle

  after_create :create_other_wheel
  
  private

  def create_other_wheel
    if self.vehicle.wheels.count >= self.vehicle.wheel_limit - 1 # skip next step
      Wheel.skip_callback(:create, :after, :create_other_wheel) 
      self.vehicle.wheels.create(diameter: self.diameter)
      Wheel.set_callback(:create, :after, :create_other_wheel) # enable the callback before return
    else
      self.vehicle.wheels.create(diameter: self.diameter)
    end
  end
end
Bicycle.create(age: 2).wheels.count
=> 2

The problems here are:

  • This is not threadsafe as the callback is disabled and then enabled on class level
  • You explicitly need to enable the callback back after you exit the chain which is just annoying.

The cleanest solution so far for this problem is to define a boolean flag using attr_accessor and pass it into the instance just as it gets created, to either halt or continue the callback chain:

class Wheel < ActiveRecord::Base
  attr_accessor :wheel_needed
  belongs_to :vehicle

  after_create :create_other_wheel, if: :wheel_needed
  
  private

  def create_other_wheel
    self.vehicle.wheels.create(diameter: self.diameter, wheel_needed: self.vehicle.wheels.count + 1 < self.vehicle.wheel_limit)
  end
end

This way, once we have enough wheels a boolean flag is set during the next chain link that determines whether we continue with another callback or not.

The only problem here is that when a vehicle is created and the after_create callback for Vehicle is fired to create the first wheel, we have to also now supply a wheel_needed attribute to start creating wheels, otherwise only a single wheel will be created as the if check above for create_other_wheel will fail.

To overcome this the following minor change will suffice:

class Wheel < ActiveRecord::Base
  attr_accessor :wheel_needed
  belongs_to :vehicle

  after_initialize :set_wheel_needed, if: -> { self.wheel_needed.nil? }
  after_create :create_other_wheel, if: :wheel_needed
  
  private

  def set_wheel_needed
    self.wheel_needed = self.vehicle.wheel_limit > 1 ? true : false
  end

  def create_other_wheel
    self.vehicle.wheels.create(diameter: self.diameter, wheel_needed: self.vehicle.wheels.count + 1 < self.vehicle.wheel_limit)
  end
end

Now we assign by default true as an initial value, if the wheel_limit is larger than 1 (why not support unicycles?)