How requirements shaped my code, AKA Rails 5 and ActiveRecord before_destroy callbacks

I recently started a new project with Rails 5, and at some point I found out the code was not behaving as expected.
The initial scenario was quite simple, Admins can have many Phones, as this code suggests:


class Admin  < ApplicationRecord
  has_many :phones
end

class Phone < ApplicationRecord
  belongs_to :admin
end

New requirements add new issues

Then application requirements changed as follows:

“Admins can add/remove phones, but are not allowed to destroy the last remaining one.”

After reading the requirements, my first guess was to use a before_destroy callback in the Phone model. If the callback returns false, the destroy operation should be halted:


class Phone  < ApplicationRecord
  belongs_to :admin

  before_destroy :dont_destroy_the_last_admin_phone

  private

  def dont_destroy_the_last_admin_phone
    return false if admin.phones.count < 2
  end
end

But much to my surprise this doesn’t work anymore. There’s not much information on this ActiveRecord new feature on the web, but according to the current documentation the behavior has changed:

“If the before_validation callback throws :abort, the process will be aborted and ActiveRecord::Base#save will return false. If ActiveRecord::Base#save! is called it will raise an ActiveRecord::RecordInvalid exception. Nothing will be appended to the errors object.”

So, the working implementation became as follows:


class Phone  < ApplicationRecord
  belongs_to :admin

  before_destroy :dont_destroy_the_last_admin_phone

  private

  def dont_destroy_the_last_admin_phone
    throw(:abort) if admin.phones.count < 2
  end
end

This is the first time in 10 years using Ruby that I see real usage for a throw statement, so my face is still a bit frowned.
But the reason behind this change is very sound: there are situations where a callback may return false but the intended behavior is not to halt the events chain. In the past if you experienced that issue you needed to explicitly return true in the callback method, which was a little quirky.

Upgrading old applications

So what happens if you update your old rails 4 app to rails 5? That should not be a problem, since this new behavior is active by default only on freshly new created apps, see config/initializers/new_framework_defaults.rb file:


# Do not halt callback chains when a callback returns false. Previous versions had true.
ActiveSupport.halt_callback_chains_on_return_false = false

Final implementation

By the way, my final implementation was completely different. I was not much satisfied with my callback code, because I felt the behavior was not very visible within the before_destroy callback, and, as I wrote before, that throw :abort still makes me frown 😉

The requirement could be stated as follows, which drives a totally different and clearer implementation, in my opinion:

“Admins must always have at least one phone”

So my final code was:


class Admin  < ApplicationRecord
  has_many :phones

  validates :phones, presence: true
end

class Phone < ApplicationRecord
  belongs_to :admin
end

Much better, isn’t it?

There is one slight difference with the previous implementation here: now you can actually destroy a Phone record even if it’s the last one for the given admin, for example via rails console. The enforcement is only at the Admin model level. This is not real a problem in the application, since in the web interface you always access phones in the context of the Admin.

TL;DR

My giveaways with this article are two:

  • be aware of the recent changes with ActiveRecord callbacks
  • the requirements wording may be as strong as to drive your actual implementation, at least for me

Leave a Reply

wpDiscuz