Photo by Mike van den Bos on Unsplash
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..