From the translator: I offer you 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 code quality and security for 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 tracking large applications. Encapsulating domain logic in models is better than putting this logic in controllers, but such models usually violate the
Single Responsibility Principle . For example, if you put everything that belongs to the user to the
User class, this is far from the only responsibility.
In the early stages, it is fairly easy to follow the SRP principle: model classes manage only the interaction with the database and connections, but gradually they grow, and the objects that were initially responsible for interacting with the repository actually become owners of all business logic. After a year or two, you get a class
User with more than 500 lines of code and hundreds of methods in a public interface. To understand this code is very difficult.
As the internal complexity grows (read: add 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 superbly charged objects with well-defined interfaces.
Perhaps you think that in Rails it’s very hard to follow OOP principles. I thought the same, however, after spending some time on the experiments, I found that Rails as a framework does not interfere with OOP at all. This is all due to the Rails agreement, or rather the lack of agreements governing the complexity management of ActiveRecord, models that are easy to follow. Fortunately, in this case, we can apply object-oriented principles and practices.
')
Do not select mixins from models.
Let's eliminate this option right away. I categorically do not recommend moving some of the methods of their large model to concerns or modules, which will later be included in the same model. Composition is preferable to inheritance. Using mixins is like cleaning a dirty room by pushing debris in the corners. At first it looks cleaner, but such “corners” complicate the understanding of the already intricate logic in the model.
Now we start refatoring!
Refactoring
1. Selecting Value Objects
A value object is a simple object that can be easily compared with another one by the contained value (or values). Usually such objects are immutable. Date, URI, and Pathname are examples of value objects from the standard Ruby library, but your application can (and almost certainly will) determine objects — values ​​that are specific to the subject area. Selecting them from models is one of the easiest refactorings.
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 an excellent candidate for selection into a separate class.
In the example, in the messaging application, you can use the
PhoneNumber value
object , and in the application associated with cash transactions, the Money value object can be useful. 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 have done so) use an instance of a regular 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
In addition to reducing the size of the class
ConstantSnapshot , this approach has several advantages:
- Methods #worse_than? and #better_than? provide a more expressive way to compare ratings than embedded Ruby operators> and <
- Definition of methods #hash and #eql? allows you to use the object of the Rating class as a hash key CodeClimate uses this to conveniently group classes and modules by rating using Enumberable # group_by .
- The #to_s method allows to interpolate an object of the Rating class into a string without additional efforts.
- This class is a convenient place for a factory method that returns the correct rating for a given “correction price” (the time required to eliminate all “smells” of this class)
2. The 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:
- The action is difficult (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 classes Order , CreditCard and Customer )
- The action has interaction with an external service (for example, sharing in social networks)
- The action is not directly related to the underlying model (for example, clearing overdue 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 apply 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 controller 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. Selecting Form Objects
When multiple models can be updated with a single form submission, this action can be encapsulated in a form object. This is much cleaner than using
accepts_nested_attributes_for , which, in my opinion, should be declared as deprecated. A good example is the submission of 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
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 interaction with the database becomes too complex, you can combine this approach with the creation of a service object. In addition, validations are often context-sensitive, so you can define them directly where they apply, instead of placing all validations in the model, for example, validating that a user has a password is required only when creating a new user and changing the password, no need to check this every time the user data changes (you are not going to put a change in user data and a password change form on one type?)
4. Selecting Query Objects
When complex SQL queries appear (in static methods and scope) it is worthwhile to put them into a separate class. Each request object is responsible for sampling according to a specific business rule. For example, an object - a request for finding the completed trial periods (
apparently, we mean trial-periods of acquaintance with Code Climate ) might 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
This class can be used in the background to send emails:
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 requests using the 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 the presence of rows located 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 implemented an alternative interface for this application, for example, voice-controlled, would I need this method?”. If not, it is worth moving it to the helper or (even better) to the view object.
For example, a ring chart in Code Climate breaks class ratings based on a snapshot of the code status. These actions are encapsulated in a view object:
class DonutChart def initialize(snapshot) @snapshot = snapshot end def cache_key @snapshot.id.to_s end def data
I often find one-to-one relationships between ERB (or Haml / Slim) sorts and patterns. This gave me the idea of ​​using the
Two Step View template, but I don’t have a formulated solution for Rails yet.
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 called a “template”. In order to avoid ambiguity, I sometimes call objects of the form “model of view” (“View Models”).
6. Selecting 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 confirmed and has used the application in the last two weeks. You can also use rule objects to group several business rules, such as the Authorizer object, which determines what data the user has access to.
The rule objects are similar to the service objects, however I use the term “object service” for write operations, and “object is a 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 increase the functionality of existing operations and therefore are similar in their action with callbacks. For cases when the logic of callbacks is used only once or when their inclusion in the model places too many responsibilities on it, it is useful to use a decorator.
Creating a comment on a blog post may cause the creation of a comment on a wall on Facebook, but this does not mean that this logic must necessarily be in the class
Comment . Slow and fragile tests or strange side effects in unrelated tests are a sign that you put too much logic in callbacks.
Here's how you can put the logic of posting a comment on Facebook to a 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 are different from service objects, 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 standard Ruby library provides a
set of tools to simplify the creation of decorators using metaprogramming.
Conclusion
Even in a Rails application, there are many model complexity controls. None of them will require violation of the principles of the framework.
ActiveRecord is an excellent library, but it can fail if you only rely 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, to get an application that is easier to maintain.
You probably noticed that most of the templates described are very simple, these objects are just Plain Old Ruby Objects (PORO), which perfectly illustrates the convenience of using the OO approach in Rails.