This article was original posted over here on the Super Good Software website.
Working on applications that overuse ActiveRecord callbacks can be painful. Saving or updating any given record might cause a cascade of API calls and business logic that's totally irrelevant to what you're trying accomplish. I've got a great trick for working around troublesome callbacks by allowing you to easily prevent them from running as needed.
I strongly believe that business logic shouldn't be implemented using ActiveRecord callbacks. Callbacks are great for data normalization and caching computed values. Sending e-mails, making API calls, and other side-effects should be implemented using some other programming pattern that untangles the logic from your persistence layer. Excessive use of callbacks leads to slow test suites, brittle systems, and unintended changes.
I run into many applications with complex third-party API integrations that are fueled by these cascades of after_save
, after_create
, and after_commit
callbacks and are difficult to understand and debug. Ideally I'd love to untangle these messes and pull out the business logic into classes that can easily be understood and tested separately from the persistence layer, but I don't always have the time to do that.
Imagine this situation: you've got an Address
model with a before_save
callback that fetches and sets the latitude and longitude for that address. You're on a deadline and don't have the time to refactor every location in the code where you create an address. The callback is also slowing down your test suite because it's making slow requests out to the geolocation API every time you create an address. What's more, you've found some locations in the app where you create addresses and already know the coordinates. In these scenarios you don't need to do the lookup, but the lookup is being performed anyway.
You could address the test suite speed issue by using something like VCR, but with a few hundred tests creating addresses, that'll generate a ton of cassettes and doesn't solve the problem of the unnecessary API calls anyway.
There's a solution that alleviates both these problems with minimal effort. Let's say our address class looks like this:
class Address
before_save :set_geolocation
private
def set_geolocation
# Hit some API or something...
end
end
What we can do is add an attr_accessor
to control whether we want to perform geolocation and then condition the callback on that attribute. (attr_accessor :disable_geolocation
is a handy shorthand for defining a disable_geolocation
reader method and a disable_geolocation=
writer method.)
class Address
attr_accessor :disable_geolocation
before_save :set_geolation, unless: :disable_geolocation
private
def set_geolocation
# Hit some API or something...
end
end
Now when updating or creating our addresses we can pass in this attribute to control whether geolocation is performed:
# This one will still cause geolocation:
address = Address.create(
line1: "910 Government St",
city: "Victoria",
province: "British Columbia",
country: "Canada"
)
# Here we prevent geolocation from running:
address.update(
disable_geolocation: true,
line1: "1328 Douglas St"
)
This works because ActiveRecord methods like update
and create
basically just assign the values you pass in, so it doesn't matter that disable_geolocation
isn't backed by a database column. This also means that you can update your factory definitions:
FactoryBot.define do
factory :address do
name { "Jardo Namron" }
sequence(:street_address) { |n| "#{n} Fake St." }
city { "Vancouver" }
province { "British Columbia" }
country { "Canada" }
disable_geolocation { true }
end
end
When you create addresses using the factory you won't get geolocation by default, but can opt in as needed.
# No geolocation!
address1 = FactoryBot.create(:address)
# Yes geolocation!
address2 = FactoryBot.create(:address, disable_geolocation: false)
This pattern comes in really handy when you don't have the time to make the larger architectural changes to remove the offending callbacks altogether. It's definitely a hack; externally controlling an object's behaviour like this is an antipattern by my standards, but it extends well to more complex situations and cleanly addresses the immediate problem, so I hope you find it useful.
Top comments (2)
Used in moderation callbacks work well, but with this greener workforce entering the market it's hard to imbue this discipline of practicality so we can avoid abstractions.
I like this practical implementation as oppose to Services Object which never seem to get implemented correctly.
The term "service object" is pretty overloaded and means a lot of things to different people, but it's still my goto technique for managing this particular kind of complexity.
Alternatively, if you app is still very CRUD-y, wrapping things like "address creation" in their own special
ActiveModel::Model
object can be really nice since you can still interact with it roughly the same way you would any other resource. It's certainly a nice option especially for less complex apps that don't necessarily have a preferred pattern for pulling out logic into POROs.