labs refactoring ruby

ActiveRecord Refactoring: Move Association Behavior to Associated Class

In a typical Rails app, one ActiveRecord model tends to accumulate a lot of associations and related methods. This is usually the User class; e.g., the User has many posts, comments, contacts, projects, etc. It’s also common to have a few instance methods to filter these associations, e.g., User#unpublished_posts, or User#recent_contacts.

Soon this God class becomes overwhelmed by all of these responsibilities. The intent of this refactoring is to move this behavior to the associated class.

Knowing Too Much

The following ActiveRecord model has a few associations (to keep this example simple), and an instance method to filter one of them.


class User < ActiveRecord::Base
  belongs_to :account
  has_many :projects
  has_many :contacts

  def active_projects
    projects.where(active: true)
  end
end

User#active_projects seems to know too much about the domain’s concept of an active project. Let’s move it to the Project class.

Moving Responsibilities

User#active_projects can be converted to a class method on Project.


class Project < ActiveRecord::Base
  def self.active_projects_for(user_id)
    where(user_id: user_id)
      .where(active: true)
  end
end

We now need to update senders, e.g., ProjectsController#index, to use this new class method.


class ProjectsController < ActionController::Base
  def index
    @projects = current_user.active_projects
  end
end

becomes:


class ProjectsController < ActionController::Base
  def index
    @projects = Project.active_projects_for(current_user.id)
  end
end

Responsibilities in Their Right Place

We can now remove the projects association and related instance method from User. User becomes simpler, and responsibilities feel like they’re in their right place.

Replace Class Query Method with Query Object

If the associated class starts to accumulate too much behavior, or we just don’t like class methods, then we could introduce a query object.


class ActiveProjectsQuery
  def initialize(relation = Project.scoped)
    @relation = relation
  end

  def for(user_id)
    @relation
      .where(user_id: user_id)
      .where(active: true)
  end
end

Question Bidirectional Associations

When a new feature requires an ActiveRecord association, avoid the tendency to automatically make it bidirectional. Bidirectional associations often result in too many parent class responsibilities. Instead, implement these responsibilities in the associated class. It’s more work, but your classes will stay small, cohesive, and ungodlike.