7 refactoring for large ActiveRecord models

From a translator: I bring to your attention a free translation of an article from the Code Climate blog entitled 7 Patterns to Refactor Fat ActiveRecord Models .
Code Climate is a powerful tool for analyzing the quality of code and the security of Ruby on Rails applications.

Introduction


When developers start using Code Climate to improve the quality of their Rails code, they have to avoid “swelling” the code of their models, since models with a lot of code create problems when maintaining large applications. Encapsulating domain logic in models is better than putting that logic in controllers, but such models usually violate the Single Responsibility Principle . For example, if you place everything that relates to the user in the User class , this is far from the only responsibility.

Following the SRP principle in the early stages is quite easy: model classes only control interaction with the database and relationships, but they gradually grow, and the objects that were originally responsible for interacting with the repository actually become the owners of all business logic. After a year or two, you will receive a User class with more than 500 lines of code and hundreds of methods in the public interface. Understanding this code is very difficult.

As the internal complexity grows (read: adding features) to your application, you need to distribute the code between a set of small objects or modules. This requires constant refactoring. As a result of following this principle, you will have a set of small, highly responsive objects with well-defined interfaces.

You may think that it is very difficult to follow the principles of OOP in Rails. I thought the same way, however, after spending a little time on the experiments, I found that Rails as a framework absolutely does not interfere with OOP. The whole blame is the Rails agreement, or rather, the lack of agreements governing ActiveRecord complexity management - models that would be easy to follow. Fortunately, in this case we can apply object-oriented principles and practices.

Do not select mixins from models


Let's immediately exclude this option. I strongly advise against transferring some of the methods of their large model to concerns or modules, which will then be included in the same model. Composition is preferable to inheritance. Using mixins is like cleaning a dirty room by pushing garbage in the corners. At first it looks cleaner, but such “angles” complicate the understanding of the already confusing logic in the model.

Now let's start reforring!

Refactoring


1. Highlight Value Objects

A value object is a simple object that can be easily compared to another by the contained value (or values). Usually such objects are immutable. Date, URI, and Pathname are examples of value objects from the Ruby standard library, but your application can (and almost certainly will) define objects - values ​​that are domain specific. Distinguishing them from models is one of the simplest refactoring.

In Rails, value objects are great for use as attributes or small attribute groups that have logic associated with them. An attribute that is more than a text field or a counter is a great candidate for being allocated as a separate class.

For example, in a messaging application, you can use the PhoneNumber value object, and in the application related to monetary transactions, the Money value object may come in handy. Code Climate has an object - a value called Rating , which is a simple rating scale from A to F that each class or module receives. I could (at the beginning this was done) use an instance of the usual string, but the Rating class allows me to add behavior to the data:

class Rating
  include Comparable
  def self.from_cost(cost)
    if cost <= 2
      new("A")
    elsif cost <= 4
      new("B")
    elsif cost <= 8
      new("C")
    elsif cost <= 16
      new("D")
    else
      new("F")
    end
  end
  def initialize(letter)
    @letter = letter
  end
  def better_than?(other)
    self > other
  end
  def <=>(other)
    other.to_s <=> to_s
  end
  def hash
    @letter.hash
  end
  def eql?(other)
    to_s == other.to_s
  end
  def to_s
    @letter.to_s
  end
end

Each instance of the ConstantSnapshot class provides access to the rating object in its public interface as follows:

class ConstantSnapshot < ActiveRecord::Base
  # …
  def rating
    @rating ||= Rating.from_cost(cost)
  end
end

In addition to reducing the size of the ConstantSnapshot class , this approach has several more advantages:
  • #Worse_than methods ? and #better_than? provide a more expressive way to compare ratings than the built-in Ruby operators> and <
  • Defining #hash and #eql methods? makes it possible to use an object of class Rating as a hash key. CodeClimate uses this to conveniently group classes and modules by rating using Enumberable # group_by .
  • The #to_s method allows interpolating an object of class Rating into a string without additional efforts
  • This class is a convenient place for the factory method, which returns the correct rating for a given “correction price” (the time required to eliminate all the “smells” of a given class)

2. Selection of service objects (Service Objects)

Some actions in the system justify their encapsulation in service objects. I use this approach when an action meets one or more criteria:
  • Complex action (for example, closing the ledger at the end of the accounting period)
  • The action includes working with several models (for example, an electronic purchase may include objects of the Order , CreditCard and Customer classes )
  • The action interacts with an external service (for example, sharing on social networks)
  • The action is not directly related to the underlying model (for example, clearing expired orders after a certain period of time)
  • There are several ways to perform this action (for example, authentication through an access token or password). In this case, you should use the GoF-pattern Strategy.

For example, we can transfer the User # authenticate method to the UserAuthenticator class :

class UserAuthenticator
  def initialize(user)
    @user = user
  end
  def authenticate(unencrypted_password)
    return false unless @user
    if BCrypt::Password.new(unencrypted_password) == @user.password_digest
      @user
    else
      false
    end
  end
end


In this case, the SessionsController will look like this:

class SessionsController < ApplicationController
  def create
    user = User.where(email: params[:email]).first
    if UserAuthenticator.new(user).authenticate(params[:password])
      self.current_user = user
      redirect_to dashboard_path
    else
      flash[:alert] = "Login failed."
      render "new"
    end
  end
end

3. Selection of form objects (Form Objects)

When several models can be updated with one form submission, this action can be encapsulated in the form object. This is much cleaner than using accepts_nested_attributes_for , which in my opinion should be declared deprecated. A good example is sending a registration form, as a result of which the Company and User records should be created :

class Signup
  include Virtus
  extend ActiveModel::Naming
  include ActiveModel::Conversion
  include ActiveModel::Validations
  attr_reader :user
  attr_reader :company
  attribute :name, String
  attribute :company_name, String
  attribute :email, String
  validates :email, presence: true
  # … more validations …
  # Forms are never themselves persisted
  def persisted?
    false
  end
  def save
    if valid?
      persist!
      true
    else
      false
    end
  end
private
  def persist!
    @company = Company.create!(name: company_name)
    @user = @company.users.create!(name: name, email: email)
  end
end


To achieve attribute behavior similar to ActiveRecord, I use gem Virtus . Form objects look like ordinary models, so the controller remains unchanged:

class SignupsController < ApplicationController
  def create
    @signup = Signup.new(params[:signup])
    if @signup.save
      redirect_to dashboard_path
    else
      render "new"
    end
  end
end


This works well for simple cases, as in the example shown, however, if the logic of interacting with the database becomes too complex, you can combine this approach with creating a service object. In addition, validations are often context-sensitive, therefore, they can be determined directly where they are used, instead of putting all validations into the model, for example, validation for the user’s password is required only when creating a new user and when changing the password, no the need to check this every time the user data is changed (are you not going to put the change in user data and the password change form in one view?)

4. Selection of query objects (Query Objects)


When complex SQL queries appear (in static methods and scope), it is worth putting them into a separate class. Each request object is responsible for the selection according to a specific business rule. For example, an object - a request for finding completed trial periods ( apparently we mean trial periods of familiarization with Code Climate ) may look like this:

class AbandonedTrialQuery
  def initialize(relation = Account.scoped)
    @relation = relation
  end
  def find_each(&block)
    @relation.
      where(plan: nil, invites_count: 0).
      find_each(&block)
  end
end


Such a class can be used in the background to send letters:

AbandonedTrialQuery.new.find_each do |account|
  account.send_offer_for_support
end


Using the methods of the ActiveRecord :: Relation class, it is convenient to combine queries using composition:

old_accounts = Account.where("created_at < ?", 1.month.ago)
old_abandoned_trials = AbandonedTrialQuery.new(old_accounts)


When testing such classes, it is necessary to check the result of the query and the selection from the database for rows in the correct order, as well as for the presence of join-s and additional queries (to avoid bugs like N + 1 query).

5. View Objects

If some methods are used only in the presentation, then they have no place in the model class. Ask yourself: “If I were to implement an alternative interface for this application, for example voice-driven, would I need this method?” If not, you should transfer it to a helper or (even better) to a view object.

For example, a donut chart in Code Climate breaks down class ratings based on a snapshot of the state of the code. These actions are encapsulated in an object of the form:

class DonutChart
  def initialize(snapshot)
    @snapshot = snapshot
  end
  def cache_key
    @snapshot.id.to_s
  end
  def data
    # pull data from @snapshot and turn it into a JSON structure
  end
end

I often find one-to-one relationships between views and ERB (or Haml / Slim) patterns. This gave me the idea of ​​using the Two Step View template , however, I still do not have a formulated solution for Rails.

Note: The term “Presenter” is accepted in the Ruby community, but I avoid it because of its ambiguity. The term "Presenter" was proposed by Jay Fields to describe what I call an object - a form. In addition, Rails uses the term “view” to describe what is commonly referred to as a “template”. To avoid ambiguity, I sometimes call view objects “View Models”.

6. Selection of rule objects (Policy Objects)

Sometimes complex read operations deserve separate objects. In such cases, I use policy objects. This allows you to remove side logic from the model, for example, checking users for activity:

class ActiveUserPolicy
  def initialize(user)
    @user = user
  end
  def active?
    @user.email_confirmed? &&
    @user.last_login_at > 14.days.ago
  end
end


Such an object encapsulates one business rule that checks whether the user's email has been verified and whether he has used the application in the last two weeks. You can also use rule objects to group several business rules, for example, an Authorizer object that determines which data a user has access to.

Rule objects are similar to service objects, however I use the term “service object” for write operations, and “object rule” for read operations. They are also similar to query objects, but query objects are used only to execute SQL queries and return results, while rule objects operate on domain models already loaded into memory.

7. Highlighting decorators

Decorators allow you to build functionality on existing operations and are therefore similar in effect to callbacks. For cases when the callback logic is used once or when their inclusion in the model entails too many responsibilities, it is useful to use a decorator.

Creating a comment on a blog post can cause a comment to be created on the wall on Facebook, but this does not mean that this logic must be in the Comment class . Slow and fragile tests or strange side effects in unrelated tests are a sign that you have put too much logic in the callbacks.

Here's how you can pull Facebook comment commenting logic into the decorator:

class FacebookCommentNotifier
  def initialize(comment)
    @comment = comment
  end
  def save
    @comment.save && post_to_wall
  end
private
  def post_to_wall
    Facebook.post(title: @comment.title, user: @comment.author)
  end
end


The controller might look like this:

class CommentsController < ApplicationController
  def create
    @comment = FacebookCommentNotifier.new(Comment.new(params[:comment]))
    if @comment.save
      redirect_to blog_path, notice: "Your comment was posted."
    else
      render "new"
    end
  end
end


Decorators differ from objects - services, as they expand the functionality of existing objects. After wrapping, the decorator object is used in the same way as a regular Comment object . The Ruby standard library provides a set of tools to simplify the creation of decorators using metaprogramming.

Conclusion


Even in a Rails application, there are many controls for model complexity. None of them will require a violation of the principles of the framework.

ActiveRecord is an excellent library, but it can also fail if you rely only on it. Not every problem can be solved by means of a library or framework. Try to limit your models only to the logic of interaction with the database. Using the presented techniques will help to distribute the logic of your model and as a result get an application that is easier to maintain.

You probably noticed that most of the described templates are very simple, these objects are just Plain Old Ruby Objects (PORO), which perfectly illustrates the convenience of using the OOP approach in Rails.

Also popular now: