Working Around ActiveRecord Callbacks

Written by Jared Norman
Published December 10, 2019

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


  def set_geolocation
    # Hit some API or something...

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


  def set_geolocation
    # Hit some API or something...

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:
  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 }

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.

Get notified when we publish new articles!
You'll get new articles, bonus Ruby and Rails tips and other useful links all delivered right to your inbox. No spam, just helpful advice on how to build great software.
© 2020 Super Good Software, Inc.