Optional Behavior in Rails Concerns

When almost all behavior must be shared

February 5, 2017
rails ruby web

Opening Image

I came across this while working with multiple somewhat similar user models. Originally, I had chosen to use Single Table Inheritance (STI), but opted to split the models because of increasing orthogonality. Luckily, Rails offers concerns which allows for multiple models to easily share functionality without having to use inheritance!

Sometimes, however, models sharing the same concern may need slightly different behavior. In my case, all 3 models had both a phone number field, though for one model in particular (the Client), the presence of phone_number was not required.

The trick was to use class_attribute in the ‘included’ section of the concern, and then in the Client model, add self.phone_number_optional = true.

# app/models/concerns/phonable.rb

module Phonable
  extend ActiveSupport::Concern

  included do
    class_attribute :phone_number_optional
    phony_normalize :phone_number, default_country_code: 'US'
    validates :phone_number, presence: true, unless: :phone_number_optional
    validates :phone_number, phony_plausible: true
# app/models/client.rb

class Client < ApplicationRecord
  include Phonable

  self.phone_number_optional = true

The easiest thing was to use the inverse, hence phone_number_optional instead of phone_number_required, as the default of the class_attribute would be nil and therefore falsey.

Remember to test both options when testing the concern, as the concern supports both cases. If you’re just testing the model that uses the concern, then you simply have to test for whatever case the model requires.

I found this to be a pretty clean solution to this somewhat messy issue, at least cleaner than duplication or throwing a lambda to evaluate a class literal into the validation conditional.