Close
Glad You're Ready. Let's Get Started!

Let us know how we can contact you.

Thank you!

We'll respond shortly.

LABS
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.

Comments
  1. Robbie Clutton says:

    Moving the relation from one class to another feels a little off, but I’m a fan of query objects for this sort of use case as Bryan discusses in his ‘fat models’ post: http://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-activerecord-models/

  2. Ben Simpson says:

    A quick note on security with this refactoring: Without chaining through user, the query for projects will not automatically be constrained to the user, leaving this work to be done by the developer. The approach taken here is to pass current_user.id to the Project#active_projects_for method. If current_user#id is nil, this could mistakenly return all projects that are not assigned to any users. This is different from the user constrained approach where the most data would could ever mistakenly return is all of a user’s projects. Its nothing that can’t be explicitly handled, but it is something that AR associations give you for free.

  3. Jared Carroll says:

    @Ben,

    Good point. That’s definitely a trade-off of this design. Maybe Project.active_projects_for could raise something in that exceptional situation.

  4. Brian Gates says:

    You don’t need the user_id argument. Just chain a class method on the associated class to the association:

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

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

  5. Ken Mayer says:

    class Project < ActiveRecord::Base

    scope :active, where(active: true)
    end

    is also chainable, e.g.

    current_user.projects.active

    and puts the "active" SQL logic in the right place

Post a Comment

Your Information (Name required. Email address will not be displayed with comment.)

* Copy This Password *

* Type Or Paste Password Here *