DEV Community

Cover image for Working Around ActiveRecord Callbacks
Jared Norman for Super Good

Posted on • Originally published at supergood.software

Working Around ActiveRecord Callbacks

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)

Collapse
 
andrewbrown profile image
Andrew Brown 🇨🇦

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.

Collapse
 
supergoodjared profile image
Jared Norman

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.