📜 ⬆️ ⬇️

7 patterns of refactoring of thick models in Rails

Thick models are difficult to maintain. Of course, they are better than controllers cluttered with the logic of the domain, but, as a rule, they violate the Single Responsibility Principle (SRP). “Everything the user does” is not single responsibility.
At the beginning of the SRP project is observed easily. But over time, models become the de facto place for business logic. And two years later, the User model has more than 500 lines of code and 50 methods in public.
The design goal is to decompose a growing application in small encapsulated objects and modules. Fat models, skinny controllers - the first step in refactoring, so let's do the second one.
You probably think it's hard to use OOP in Rails. I thought so too. But after some explanations and experiences, I realized that Rails is not so obstructed by OOP. Rails conventions should not be changed. But we can use OOP and best practices where Rails has no agreements.

Do not break the model into modules


Let's go without it. I do not approve the introduction of methods into modules if they are connected only in one model. Using modules in this way is as if stuffing all things in a room under the bed and in the closet. Of course, the code in the model becomes less, but debugging and refactoring such code is difficult.
Now about refactoring.

1. Highlight Value Objects


Value Objects are simple objects for storing values, such as money or a range of dates, the equality of which depends on their values. Date, URI, Pathname are examples from the standard Ruby library, but you can define your own.
In Rails, Value Objects are a great solution if you have multiple attributes and associated logic. For example, in my SMS sharing application was PhoneNumber. Online store need Money. Code Climate has a Rating - class rating. I could use a String, but both the data and the logic are defined in Rating:
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 

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

Advantages of this approach:

2. Highlight Service Objects


I create Service Objects if the action:

For example, you can make the User # authenticate method in a UserAuthenticator:
 class UserAuthenticator def initialize(user) @user = user end def authenticate(unencrypted_password) return false unless @user if BCrypt::Password.new(@user.password_digest) == unencrypted_password @user else false end end end 

And 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. Highlight Form Objects


When sending one form changes several models, the logic can be rendered in the Form Object. This is much cleaner than accepts_nested_attributes_for , which, IMHO, generally should be removed. Here is an example of a registration form that creates Company and User:
 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 

I used Virtus to get attributes with behavior like ActiveRecord. So in the controller I can do this:
 class SignupsController < ApplicationController def create @signup = Signup.new(params[:signup]) if @signup.save redirect_to dashboard_path else render "new" end end end 

For simple cases, it works like this. If the data storage logic is complex, you can combine this approach with the Service Object. As a bonus: here you can place validations, and not smear them on model validations.
')

4. Highlight Query Objects


For complex SQL queries that weight your models, select Query Objects. Each Query Object executes one business rule. For example, Query Object, returning abandoned accounts:
 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 

You can use the background job to send mail:
 AbandonedTrialQuery.new.find_each do |account| account.send_offer_for_support end 

ActiveRecord :: Relation are first class objects in Rails 3, so you can pass them as input parameters to the Query Object. And we can use a combination of Relation and Query Object:
 old_accounts = Account.where("created_at < ?", 1.month.ago) old_abandoned_trials = AbandonedTrialQuery.new(old_accounts) 

Do not get carried away with isolated testing of such classes. Use both the object and the database in tests to make sure that the answer is correct and that there are no unexpected effects like N + 1 SQL query.

5. Highlight View Objects


If the method is needed only to display data, it should not belong to the model. Ask yourself: “If the application has, for example, a voice interface, will this method be needed?”. If not, move it to the helper or to the View Object.
For example, a ring chart in Code Climate shows the ratings of all classes in a project (for example, Rails on Code Climate ), and is based on a project code snapshot:
 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 

Most often, for one View Object I have one ERB template (HAML / SLIM). So now I understand using the Two Step View pattern in Rails.

6. Highlight Policy Objects


Sometimes complex read operations deserve their own objects. In this case, I create a policy object. This allows you to take out of the model logic, which has no direct relation to the model. For example, users who are rated as active:
 class ActiveUserPolicy def initialize(user) @user = user end def active? @user.email_confirmed? && @user.last_login_at > 14.days.ago end end 

A policy object describes one business rule: a user is considered active if his mail is confirmed and he logged in no earlier than two weeks ago. You can use Policy Objects for a set of business rules, such as the Authorizer, which describes what data the user has access to.
Policy Objects are similar to Service Objects, but I use the Service Object for write operations and the Policy Object for reading. They are also similar to Query Objects, but Query Objects execute SQL queries, and Policy Objects use the model loaded into memory.

7. Highlight Decorators


Decorators allow the use of existing methods: they are similar to callbacks. Decorators are useful in cases where a callback must be performed under certain conditions or if it is included in the model it pollutes it.
A comment written in a blog can be posted on the commenter's Facebook wall, but this does not mean that this logic should be defined in the Comment class. A sign that you have too many callbacks are slow and fragile tests or the need to stabilize these callbacks in many places.
Here's how, for example, to endure the logic of posting on Facebook in 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 

And in the controller:
 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 Service Objects in that they use existing methods. FacebookCommentNotifier instances are used in the same way as Comment instances. Ruby makes decorators easier to do using metaprogramming.

Finally


In Rails applications, there are many complexity management techniques in models. They do not replace Rails. ActiveRecord is an excellent library, but you shouldn’t rely only on it. Try using these techniques to take some of the logic out of the models, and your applications will become easier.
You may notice that these patterns are pretty simple. Objects are just Ruby objects. This is what I wanted to convey to you. Not all tasks need to be solved by a framework or libraries.

Original article here .

Source: https://habr.com/ru/post/158011/


All Articles