Organized code made easy with ActiveSupport::Dependencies::Loadable

There are mini features included in many models and controllers — chunks of code that live alongside each other that operate successfully together. Let’s look at how to separate this code from the rest.

Contents

Note

This code assumes you’re using the classic autoloader and not Zeitwork. Zeitwork is the default autoloader in Rails 6 and doesn’t require the require_dependency method. However, if you explicitly defined :classic in config.autoloader, this will apply.

This blog post is originates from a talk I gave to fellow engineers at my current job.

Dependency hell

We’ve all heard of dependency hell. This depends on this, which depends on this, which depends on this. It’s a nasty road that nobody wants to go down, yet, we all do it.

Design decisions

When we’re programming, there’s usually a way if there’s a will. If there’s a willingness to write organized code, there’s a way. There are times that we have to put out fires and that will takes a backseat. There are times when we just don’t expect there to be any other way than the documentation, too.

But you’re searching for the way.

Concerns, or not to concern

When I talk about separating features into their own file (yes, file), people first think about ActiveSupport::Concern.

That’s not what concerns are for.

ActiveSupport::Concern is intended to be used for shared code amongst many classes. Let’s look at an example.

Concern example

Let’s say you have an Article model and a Post model. Each has_many :comments

In app/models/post.rb:

class Post
  has_many :comments 
end

In app/models/article.rb:

class Article
  has_many :comments 
end

In app/models/comment.rb:

class Comment 
  belongs_to :commentable, polymorphic: true 
  belongs_to :user 
end

This can be reduced to:

In app/models/concerns/commentable.rb:

module Commentable
  extend ActiveSupport::Concern

  included do 
    has_many :comments 
  end
end

In app/models/article.rb:

class Article
  include Commentable
end

In app/models/post.rb:

class Post
  include Commentable
end

How Rails autoloader works

When you hit PostsController in your app, whether that’s in the console or via a web request, if that constant isn’t loaded, Rails hits const_missing.

Once the chain hits const_missing(name), Rails defers to load_missing_constant.

load_missing_constant loops through Rails.config.autoload_paths, searching for files that match some pattern, like "#{name.underscore}.rb". For each that match, it then hits require, adds that file path to its cache for constant PostsController and tries again. If it’s resolved, great! If it’s not, it removes that cache item and it continues to complain.

This means that Rails assumes each file is named to its conventions so it knows where to look for it. Otherwise, we’d be using require all over the place.

A naive example

Once a user signs up, we email them a confirmation code code and expect them to enter it on our app before we mark their email as confirmed_at:

In app/models/user.rb:

class User
  attribute :confirmation_code, :string
 
  before_validation :set_code, on: :create 

  after_commit_create :send_code_email

  validate :confirmation_code, if: -> { confirmation_code.present? } do 
    unless confirmation_code == code 
      self.errors.add(:confirmation_code, "is invalid.")
      throw(:abort)
    end
  end
  
  private 
  
  def set_code
    self.code = rand(1000..10000)
  end
  
  def send_code_email
    UserMailer.code(self.id).deliver_later
  end
end

While this is fine for small applications, in more complex scenarios this class grows over time. Separating this concern — this feature — is an easy win and a high-impact win for both the short and long-term. Establishing these patterns early is good hygiene.

Using require_dependency

First we’ll split this out into its own file and then we’ll look about how require_dependency works.

In app/models/user/code_dependency.rb:

# Yes, this is class User 
class User 
  attribute :confirmation_code, :string
   
  before_validation :set_code, on: :create 
  
  after_commit_create :send_code_email
  
  validate :confirmation_code, if: -> { confirmation_code.present? } do 
    unless confirmation_code == code 
      self.errors.add(:confirmation_code, "is invalid.")
      throw(:abort)
    end
  end
    
  private 
    
  def set_code
    self.code = rand(1000..10000)
  end
    
  def send_code_email
    UserMailer.code(self.id).deliver_later
  end
end 

In app/models/user.rb:

class User 
  require_dependency 'user/code_dependency'
end

For the same result as adding everything in user.rb.

require_dependency uses require under the hood. When it sees it, it adds the path given to the current class’s constant lookup, therefore avoiding the constant resolving lookup.