To avoid making the Model file too long, move the callbacks into a Subscriber.
Scenario and Problem
- As the business becomes more complex, there are more and more instances of Models calling each other.
- However, in most cases, encapsulating a service layer is too complex.
- Consequently, a large number of operations accumulate in the callbacks, making the model file increasingly lengthy.
Solution
- Use the observer pattern to listen for model callback triggers.
- Use conventions to clearly delineate code locations.
Simple Example
When updating the product price, it is necessary to update the price of related orders.
class Product < ApplicationRecord
has_many :orders
after_update do
if price_changed?
orders.each do |order|
order.update! price: price
end
end
end
end
After Using Subscriber
# ./app/subscribers/order_subscriber.rb
subscribe_model :product, :after_update do
if price_changed?
orders.each do |order|
order.update! price: price
end
end
end
Using Conventions
- Only business interactions with other models should be written in subscribers.
- Subscribers should not modify the triggering model.
- Business code should be placed in the subscriber that consumes the trigger.
- If a single subscriber is too long, it can also be split into multiple subscribers based on the business, such as order_product_subscriber.rb.
Benefits
- The model file no longer contains a large number of callbacks.
- It is clear which other models each model needs to consume.
- Callbacks like after_save are still wrapped in a transaction.
- Supports callbacks for secondary models (triggers the callbacks of the secondary model first, then the base class’s callbacks).
- Allows for writing individual tests (based on ActiveSupport::Notifications).
Core Code
# ./config/initializers/subscribe_model.rb
def subscribe_model(model_name, event_name, &block)
ActiveSupport::Notifications.subscribe("active_record.#{model_name}.#{event_name}") do |_name, _started, _finished, _unique_id, data|
data[:model].instance_eval(&block)
end
end
class ActiveRecord::Base
class_attribute :skip_model_subscribers
self.skip_model_subscribers = false
end
%i(after_create after_update after_destroy after_save after_commit after_create_commit after_update_commit).each do |name|
ActiveRecord::Base.public_send(name) do
unless skip_model_subscribers
readonly! unless readonly?
ActiveSupport::Notifications.instrument "active_record.#{self.class.model_name.singular}.#{name}", model: self
ActiveSupport::Notifications.instrument "active_record.#{self.class.base_class.model_name.singular}.#{name}", model: self if self.class.base_class != self.class
public_send(:instance_variable_set, :@readonly, false)
end
end
end
Rails.application.config.after_initialize do
Dir[Rails.root.join('app/subscribers/*.rb')].each { |f| require f }
end
FAQ
Q: Why not use a concern?
A: Within the team, it is agreed that only when two or more models share common code should it be moved to a concern.
Q: Why not create a service layer?
A: The service layer is not intuitive enough, and for small teams, the maintenance cost is higher than that of a subscriber.