A technique for avoiding Callbacks in Rails

Part 31 of building a Rails 7 application

I have a requirement for my application where I need to make changes to one record if another record that is "shadowing" the original is updated. In this context I have a shadow product where users can make fixes such as spelling mistakes and so on, without directly editing the production instance of that product. Any fixes they make will be reflected in the production product at a later point.

First of all I will create a task that takes the changed product and updates the production instance using those attributes. Designing it this way makes this a self contained action that can be called from anywhere and easily tested.

app/models/tasks/update_external_product.rb

module Tasks
  class UpdateExternalProduct < Task
    class << self
      def executable?(changed_attributes)
        (changed_attributes.keys & updatable_attributes).any?
      end

      def updatable_attributes
        %w[
        brand item_description item_size item_measure item_pack_name item_sell_quantity item_sell_pack_name
        volume_in_litres category_id
      ]
      end
    end

    def initialize(attributes = nil)
      super
      self.description = 'Update an external product using the attributes from an updated local product'
    end

    protected

    def execute
      context.external_product.lock!.update!(context.attributes.slice(*updatable_attributes))
    rescue ActiveRecord::NotNullViolation => e
      self.error = e.full_message
      self.backtrace = e.backtrace
      error!
    end

    private

    def updatable_attributes
      self.class.updatable_attributes
    end
  end
end

It has a class method called executable? which can be queried to see if the changes that were made to the shadow product are ones that need to be propagated to the production version of the product.

And there is an execute method which does the actual work. All of this can be tested easily in isolation. These tasks can also be executed asynchronously if necessary using Sidekiq for example.

Now to actually create one of these tasks and call it. One way would be to use callbacks, so let's see what that might look like for comparison purposes:

app/models/product.rb

class Product < ApplicationRecord
  ...
  after_commit :update_external_product

  protected

  def update_external_product
    if Tasks::UpdateExternalProduct.executable?(previous_changes)
      Tasks::UpdateExternalProduct.create!(context: self).tap(&:call)
    end
  end
end

An after_commit callback is added and it calls a method which takes care of creating the task and executing it. Pretty straightforward and it works. In my controller where I save products I do not need to add anything else as the callback will automatically do the UpdateExternalProduct task as well.

However it has a few issues that are well documented in the Rails community as well. The major one being there is now a side effect to updating a product that may or may not actually be wanted and this is now hidden away in the Product class. This makes testing harder, it makes following the flow of logic harder as well when something goes wrong.

So what is an alternative? Well it isn't vastly different, but it makes what is happening more explicit. Instead of using a callback what if there was an explicit method that did both the update and the propagation of the changes using the task? It could look like this:

app/models/product.rb

  def update_and_propagate(attributes)
    saved = update(attributes)
    if saved && Tasks::UpdateExternalProduct.executable?(previous_changes)
      Tasks::UpdateExternalProduct.create!(context: self).tap(&:call)
    end
    saved
  end

Which means the controller then needs to do this instead:

app/controllers/products_controller.rb

  def update_resource
    # @resource.update(resource_params) - old way
    @resource.update_and_propagate(resource_params)
  end

Now I need to be explicit when I want the propagation side effect to occur by calling the new method update_and_propagate. Anyone reading this code will know more than just an update is occurring here. The trade off being that developers using this code will need to understand the scenarios where they want propagation to occur and remember to call this new method instead.

As always, it comes to tradeoffs, and there will still be situations where a callback better suits the application needs but consider alternatives such as the above first.

Recommend reading

dev.to/mickeytgl/the-good-and-bad-of-active..

medium.com/planet-arkency/the-biggest-rails..

spin.atomicobject.com/2018/07/28/rails-call..