Working Around ActiveRecord Callbacks
Tags: ruby, rails
Published: 2019-12-10 16:05:10

---
title: "Working Around ActiveRecord Callbacks"
published: true
description: "Here's a pragmatic strategy for getting things done when excessive ActiveRecord callbacks are getting in your way."
tags: "ruby, rails"
canonical_url: "https://supergood.software/working-around-activerecord-callbacks/"
cover_image: "https://thepracticaldev.s3.amazonaws.com/i/wnbcbt4kdguqi7vepoon.jpg"
---

_This article was original posted [over here on the Super Good Software website](https://supergood.software/working-around-activerecord-callbacks/)._

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:

```ruby
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.)

```ruby
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:

```ruby
# 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:

```ruby
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.

```ruby
# 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.