Refactor code according to Ruby on Rails best practices

Dejan Vujovic
7 min readMay 10, 2023

--

In this tutorial, I will explain what is the best practices for Ruby ​​on Rails and we will refactor the previously created code for login with FB and phone number.

If you didn’t follow the previous tutorial here, you can clone this GitHub repo. Before we start, just create a new branch from develop branch. I named it ‘refactoring’.

Before we start with refactoring, I will tell you some best practices for Ruby on Rails. Every Ruby developer should adhere to the following principles: Keep It Clean, Keep Your Code DRY, SOLID, Use Enums Wisely, and SQL Injection Prevention. The main purpose of this tutorial is to refactor existing code, so I’ll just outline some main principles. If you are not familiar with them, google and learn each of these principles well. Also see how to protect your Rails application from hacker attacks such as Cross-site-scripting, CSRF, SQL injection, or ClickJacking.

It is also very important to know the design of the pattern. Design patterns can be said as best practices that must be followed to avoid code complexity and maintain code readability. There are several important design patterns for Ruby on Rails and they are as follows: Service Objects,
View Objects (Presenter), Query Objects, Decorators, Form Objects, Value Objects, Policy Objects, Builder, Interactor, Observer.
Just google it and you will find more about these very important patterns. The basic idea of these patterns is to encapsulate business logic and complex calculations into manageable classes and methods, replacing complex conditionals and formatting methods and to split down the code into different places which can be used multiple times or to DRY the codebase and basically not to overload any model or controller. You should also learn about the mix-in pattern. In fact, for many Ruby developers, mix-ins are actually preferred over class inheritance.

Ok, let’s start with our code. We made a Rails application with login with FB and phone number in the previous tutorial. It is just the start of an application, but when as the application grows we will get fat models and controllers. Or if we need to add new features, we often see that it is not easy because many different dependencies have been created in the code. Let’s check our user model:

class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :trackable, :omniauthable, omniauth_providers: %i[facebook]

validates :phone, presence: true, unless: :skip_validation?
validates :phone, uniqueness: {case_sensitive: false}, unless: :skip_validation?
validates :phone, :format => { :with => /[0-9]{3}[- ]?[0-9]{3}[- ]?[0-9]{2}[- ]?[0-9]{2}/ }, unless: :skip_validation?

after_create :update_user_verified_column_to_true
after_create :send_pin!, unless: Proc.new { self.provider == "facebook" }

def self.from_omniauth(auth)
find_or_create_by(provider: auth.provider, uid: auth.uid) do |user|
user.email = auth.info.email
user.password = Devise.friendly_token[0, 20]
end
end

def update_user_verified_column_to_true
return unless phone.blank?
update_column(:verified, true)
end


def skip_validation?
return if provider.blank?
self.save(validate: false)
end

def perform(user)
nexmo = Nexmo::Client.new(api_key: 'cf003fcb', api_secret: 'X1Q3zcKyntxKVwTq')
resp = nexmo.sms.send( from: "Ruby", to: user.phone, text: user.pin.to_s)
user.touch(:pin_sent_at)
end

def reset_pin!
update_column(:pin, rand(1000..9999))
end

def unverify!
update_column(:verified, false)
end

def send_pin!
reset_pin!
unverify!
self.perform(self)
end

def email_required?
false
end

def email_changed?
false
end

def will_save_change_to_email?
false
end

protected

def password_required?
false
end
def password_confirmation_required?
false
end
end

We need to make this model to be simple data layer. If you cloned GitHub repository code is working well. We can log in with Facebook and also we can log in with a phone number and receive a verification code via SMS.

Let’s start from the top of the model. We can see validations for the phone. Here we can use concern. A Rails Concern is any module that extends ActiveSupport::Concern a module. A concern is a module that you extract to split the implementation of a class or module into coherent chunks, instead of having one big class body. We can just create a new file in concerns named user_validations.rb:

require "active_support/concern"

module UserValidations
extend ActiveSupport::Concern
included do
validates :phone, presence: true, unless: :skip_validation?
validates :phone, uniqueness: {case_sensitive: false}, unless: :skip_validation?
validates :phone, :format => { :with => /[0-9]{3}[- ]?[0-9]{3}[- ]?[0-9]{2}[- ]?[0-9]{2}/ }, unless: :skip_validation?
end

def skip_validation?
return if provider.blank?
self.save(validate: false)
end
end

Next, we will include our module in the user model:

 include UserValidations

If we check the code in our browser, everything works fine as before.
Here I just wanted to show you how to use Concern. We moved validation to concern and after that, we included modules in the model. We have not unloaded the model here because the model is still in charge of validation. The best practice is to create a form object and do the validation in that class. As we use gem devise here, at least we learned what concern is and how to extract validation or methods in concern. Google more to see why the concern is different from a module and when to use it.

Also, we can put in concern methods that do not require a password and email on login. Remove them from the user model. Create concerns/non_required.rb file and move them there. Just include the NonRequired module in the user model.

require "active_support/concern"

module NonRequired
extend ActiveSupport::Concern
included do

private

def email_required?
false
end

def email_changed?
false
end

def will_save_change_to_email?
false
end

protected

def password_required?
false
end
def password_confirmation_required?
false
end
end
end

Next, we can move phone regex from our concern to the lib folder. I will create a constant.rb file in the lib folder. We often need constants in a Rails application. We can put this file in the config/initializers folder as well, but Rails will find it faster in the lib folder. The lib folder is also the place where we can create a plugin in our application if necessary. Our lib/constants.rb file should be:

module Constants
PHONE_REGEX = /[0-9]{3}[- ]?[0-9]{3}[- ]?[0-9]{2}[- ]?[0-9]{2}/
end

Now we will update our concerns/user_validations.rb file:

validates :phone, format: { with: ::Constants::PHONE_REGEX }, unless: :skip_validation?

Just add the lib folder in autoload, and everything again works fine. Update config/application.rb file:

 config.eager_load_paths << root.join('lib')

Next, in our user model, I see two callbacks. Let’s move them from the model to the jobs folder. First, we will create jobs/send_pin_job.rb:

class SendPinJob < ActiveJob::Base

def perform(user)
nexmo = Nexmo::Client.new(api_key: 'cf003fcb', api_secret: 'X1Q3zcKyntxKVwTq')
resp = nexmo.sms.send( from: "My Rails App", to: user.phone, text: user.pin.to_s)
user.touch(:pin_sent_at)
end
end

And jobs/update_user_job.rb:

class UpdateUserJob < ActiveJob::Base

def perform(user)
return unless user.phone.blank?
user.update_column(:verified, true)
end
end

Our user model now should look like this:

class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :trackable, :omniauthable, omniauth_providers: %i[facebook]

include UserValidations
include NonRequired

after_create :update_user_verified_column_to_true
after_create :send_pin!, unless: Proc.new { self.provider == "facebook" }

def self.from_omniauth(auth)
find_or_create_by(provider: auth.provider, uid: auth.uid) do |user|
user.email = auth.info.email
user.password = Devise.friendly_token[0, 20]
end
end

def update_user_verified_column_to_true
UpdateUserJob.perform_now(self)
end

def reset_pin!
update_column(:pin, rand(1000..9999))
end

def unverify!
update_column(:verified, false)
end

def send_pin!
reset_pin!
unverify!
SendPinJob.perform_now(self)
end
end

Enough is good for the user model, as we don't have any logic that doesn't belong to the user model and it is just a layer for data now. We use a gem devise and therefore do not have a user controller. Otherwise, it would be possible to move the methods to the service object and call the service object in the controller. I think it’s best to keep it as it is. Also in our controllers, there is no need for refactoring, as they are simple and clean.

For the end, I will add some code, just to show you how we can use ViewComponents as an evolution of the presenter pattern, inspired by React. For this purpose, I will remove notices in the view component. Add this to your Gemfile:

gem "view_component"

Next, generate a new component:

bin/rails generate component Flash

Ok, we got the components folder in the app directory with two files: flash. component.rb and flash.component.html.erb. Just delete files related to notice and alert from views/layout/application.html.erb. New files in folder components should look like this. File flash_component.rb:

class FlashComponent < ViewComponent::Base
def initialize(notice:, alert:)
@notice = notice
@alert = alert
end
end

File flash_component.html.erb:

<div class=" mt-2 animate-fade" >
<p data-test="notice" class="text-green-500 px-4"><%= @notice %></p>
<p data-test="alert" class="text-red-500 px-4"><%= @alert %></p>
</div>

We will just add a load path to the config/application.rb file:

  config.autoload_paths += [config.root.join('app')]

And just add this in the body of views/layouts/application.html.erb file:

 <div class="position: fixed max-w-[100%] w-max flex-col top-4rem right-[50%] translate-x-[50%] items-center gap-5rem px-0 py-4 ">
<%= render(FlashComponent.new(notice: notice, alert: alert)) %>
</div>

Awesome. It works properly, and we have our first view component working. Just to make things better, let’s make an animation to notices fade after a few seconds. We use Tailwind CSS so we will put animation in tailwind.config.js. THis file should look like this:

module.exports = {
content: [
'./app/components/**/*.{erb,html}',
'./app/views/**/*.html.erb',
'./app/helpers/**/*.rb',
'./app/assets/stylesheets/**/*.css',
'./app/javascript/**/*.js'
],
theme: {
extend: {
keyframes: {
fade: {
'0%, 100%': { opacity: 0 },
'5%, 60%': { opacity: 1},
},
},
animation: {
fade: 'fade 4s ease-in-out both'
},
},
},
plugins: [

],
}

Now after 4 seconds notices expire. But, if we check with the mouse we still can see the notice is still in the Dom. One fast way is to remove it with Stimulus. Just add a new file in javascript/controller and name it removals_controller.js:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
remove() {
this.element.remove()
}
}

And just import it in the javascript/controllers/index.js file:

import RemovalsController from "./removals_controller"
application.register("removals", RemovalsController)

Update now file components/flash_component.html.erb. It should be now:

 div class=" mt-2 animate-fade" data-controller="removals" data-action="animationend->removals#remove">
<p data-test="notice" class="text-green-500 px-4"><%= @notice %></p>
<p data-test="alert" class="text-red-500 px-4"><%= @alert %></p>
</div>

We just with Stimulus Removal Controller removed the flash notice from Dom. Notices and alerts work awesome. Thank you for reading this tutorial.

In the next chapter, I will show you best practices for linters and GitHub actions.

--

--