Ruby on Rails 🚇
All Ruby conventions apply to Ruby on Rails but working with a framework adds another superset of conventions to follow.
Naming
-
Suffix each project name with
-web
or-api
depending on the project type.rails new project-name rails new project-name --api
rails new project-name-web rails new project-name-api --api
-
Use
kebab-case
for naming assets files.app/assets/images/user_avatar.svg app/assets/stylesheets/components/_card_pricing.scss public/assets/images/error_404.svg
app/assets/images/user-avatar.svg app/assets/stylesheets/components/_card-pricing.scss public/assets/images/error-404.svg
-
When adding “non-conventional” directories in
/app
, use the parent directory singular name as a suffix for filenames inside that directory.├── forms │ └── create_otp_form.rb ├── ... ├── queries │ └── package_query.rb ├── ... └── services │ └── create_user_service.rb ├── ...
-
Use the
kebab-case
project name as a namespace and prefix to name modules and engines.engines/campaign/ lib/errors/
engines/project-name_campaign/ lib/project-name/errors/
Syntax
-
Time.current
instead ofTime.now
orTime.zone.now
to use the timezone configured in the Rails environment.
Application Architecture
Standard Rails way vs. Non-standard way
To implement a new feature, developers must decide when to leverage the standard Rails way and when to use non-standard ways instead:
-
- The non-standard Rails way means using small Plain Old Ruby Objects (POROs), resulting in custom and more explicit implementations, e.g.,
For example, a form with a nested object can be done using the Rails way with the accepts_nested_attributes_for class method in the model. It can also be done using a non-standard Rails way with a form object.
The architecture arbitration between the Rails way and the non-standard Rails way must be performed for each feature.
Tools and Frameworks
Each project has different needs and might come with some existing legacy. In the absence of an explicit reason to use different tools, developers must follow the same stack as recommended in the Nimble Rails Template.
This includes – but is not limited to – the following tools:
- Frontend
- Backend
Configuration
-
Use
fetch
to access environment variables.config.action_mailer.default_url_options = { host: ENV['DEFAULT_HOST'], port: ENV['DEFAULT_PORT'] }
config.action_mailer.default_url_options = { host: ENV.fetch('DEFAULT_HOST'), port: ENV.fetch('DEFAULT_PORT') }
-
Provide a default value for non-sensitive variables when possible (i.e. not secret credentials).
ENV.fetch('NON_EXISTING_NON-SENSITIVE_VARIABLE', 'default value')
-
Prefer using an
initializer
to store environment variables as constants. Name this file asproject-name.rb
:# in config/initializers/project-name.rb BASIC_AUTHENTICATION_USERNAME = ENV.fetch('BASIC_AUTHENTICATION_USERNAME') BASIC_AUTHENTICATION_PASSWORD = ENV.fetch('BASIC_AUTHENTICATION_PASSWORD') # in app/controllers/application_controller.rb class ApplicationController < ActionController::API http_basic_authenticate_with name: BASIC_AUTHENTICATION_USERNAME, password: BASIC_AUTHENTICATION_PASSWORD end
Since this file is loaded on the application boot, it will fail if an environment variable is missing i.e. fast-fail strategy.
Models
-
Code in models must follow the same structure as Ruby classes with additional blocks.
class SomeModel < ApplicationRecord # extend and include custom modules extend SomeModule include SomeModule # refinements using ArrayExtensions # third party macros acts_as_paranoid # constants SOME_CONSTANT = '20'.freeze # attributes, enums and accessors attr_accessor :name, :last_name enum status: { inactive: 0, active: 1, blacklisted: 2 }, _suffix: true store_accessor :social_accounts, %i[facebook linkedin twitter] # relationships has_many :posts, inverse_of: :some_model belongs_to :organization, inverse_of: :some_model # delegations delegate :full_name, to: :model_decorator # validations validates :name, presence: true validates_with CustomValidatorClass validate :custom_validator_method # scopes default_scope { order(first_name: :asc) } scope :with_relationship, -> { include(:relationship) } # callbacks after_create_commit :notify_reviewer # public class methods class << self def some_method end end # initialization def initialize end # public instance methods def some_public_method end def model_decorator @model_decorator ||= ModelDecorator.new(self) end # protected methods protected def some_protected_method end # private methods private def notify_reviewer end end
-
Define explicitly enum keys to avoid both data and implementation inconsistencies. The latter is often caused when non-positive values are needed.
enum status: [:inactive, :active, :blacklisted]
enum status: { inactive: 0, active: 1, blacklisted: 2 } enum status: { inactive: -1, active: 0, blacklisted: 1 }
-
Use
inverse_of
option when defining associations to prevent avoidable SQL queries and keep models in sync.class Author < ApplicationRecord has_many :books end class Book < ApplicationRecord belongs_to :author end
class Author < ApplicationRecord has_many :books, inverse_of: :author end class Book < ApplicationRecord belongs_to :author, inverse_of: :books end
-
Use
validates
with options instead of convenience methods such asvalidates_option_name_of
.validates_presence_of :name
validates :name, presence: true
-
Do not add presentation logic in models, use Decorators instead.
Non-ActiveRecord Models
Not every model is backed by a database. When to use Concerns for appearance, when to encapsulate logic in plain-old Ruby objects. – @dhh
-
Use Single Table Inheritance (STI) to decouple models from database tables and to map better domain concerns to separate objects.
In its simplest implementation, STI requires a
type
column and each sub-model to inherit from the same parent class:class Transaction < ApplicationRecord TRANSACTION_TYPES = %w[Deposit Withdrawal].freeze validates :type, inclusion: { in: TRANSACTION_TYPES } def self.create_deposit(attributes = {}) transaction_type = 'Deposit' #... end def self.create_withdrawal(attributes = {}) transaction_type = 'Withdrawal' #... end end
class Transaction < ApplicationRecord end class Deposit < Transaction end class Withdrawal < Transaction end
In more complex implementations – where a
type
column is not possible – STI can be implemented manually usingdefault_scope
:class Transaction < ApplicationRecord enum transaction_type: { deposit: 0, withdrawal: 1 } end class Deposit < Transaction default_scope { where(transaction_type: :deposit) } def initialize(attributes = {}) super(attributes) do transaction_type = :deposit end end end
-
Use value objects to extract domain concerns.
class DateRange DATE_FORMAT = '%d-%B-%Y'.freeze attr_reader :start_date, :end_date def initialize(start_date, end_date) @start_date = start_date @end_date = end_date end def include_date?(date) date >= start_date && date <= end_date end def to_s "from #{start_date.strftime(DATE_FORMAT)} to #{end_date.strftime(DATE_FORMAT)}" end end
Which can be used in an ActiveRecord model:
class Event < ApplicationRecord def date_range DateRange.new(start_date, end_date) end def date_range=(date_range) self.start_date = date_range.start_date self.end_date = date_range.end_date end end
-
Place non-ActiveRecord models – such as value objects but not only – in the directory
/models
along with ActiveRecord-backed models.
Optimizations
Optimize relationships to avoid n+1 queries:
-
Eager load relationships if needed:
class Booking < ApplicationRecord belongs_to :photographer_package scope :with_photographer_package, -> { includes(:photographer_package) } end
In most cases, it’s better to let Rails optimize the pre-fetching logic using
includes
but in some cases, preload or eager_load must be used directly. - the gem Bullet to detect n+1 queries.Use
-
Prefer using
find_each
withbatch_size
to process large amounts of records.User.all.each do |user| NewsLetter.deliver(user) end
User.find_each(batch_size: 500).each do |user| NewsLetter.deliver(user) end
-
Prefer using
exists?
overpresent?
to check if a record exists.User.where(email: '[email protected]').present?
User.exists?(email: '[email protected]')
-
Prefer using
pluck
to get some model attributes as an array.active_user_emails = User.where(status: 'active').map(&:email)
active_user_emails = User.where(status: 'active').pluck(:email)
-
Prefer using
select
to get the records as an Active Record relation and where the records come with only some specific attributes.active_users = User.where(status: 'active')
active_users = User.where(status: 'active').select(:id, :name, :email)
-
Prefer using
insert_all
to create many records at once.users = [ { name: 'john', email: '[email protected]' }, { name: 'ricky', email: '[email protected]' }, { name: 'maria', email: '[email protected]' } ] users.each do |user| User.create(user) end
users = [ { name: 'john', email: '[email protected]' }, { name: 'ricky', email: '[email protected]' }, { name: 'maria', email: '[email protected]' } ] User.insert_all(users)
- Prefer using the
load_async
method to run multiple independent queries asynchronously. It helps to reduce the total query response time.
class DashboardsController < ApplicationController def index @users = User.active @courses = Course.active @events = Event.active end end
class DashboardsController < ApplicationController def index @users = User.active.load_async @courses = Course.active.load_async @events = Event.active.load_async end end
- Prefer using the
Routes
-
Prefer using
only
overexcept
to be more explicit about what routes are available.resources :configurations, except: [:new, :create, :destroy]
resources :configurations, only: [:index, :show, :edit, :update]
-
Prefer using
concern
to have reusable routes.resources :articles do resource :comments end resources :photos do resources :comments end
concern :commentable do resources :comments end resources :articles, concerns: :commentable resources :photos, concerns: :commentable
-
Prefer using shallow nesting to avoid deep nested routes.
resources :articles do resources :comments, only: [:index, :new, :create] end resources :comments, only: [:show, :edit, :update, :destroy]
resources :articles do resources :comments, shallow: true end
-
Prefer using
constraints
to validate requests at the route level.# In the lib/constraint/ip_validation.rb module Constraint class IPValidation def initialize @black_listed_ips = Blacklist.retrieve_ips end def matches?(request) @black_listed_ips.exclude?(request.remote_ip) end end end # In the routes.rb constraints Constraint::IPValidation.new do get 'some_path', to: 'some_controller#action' end
Controllers
-
Code in controllers must follow the same structure as Ruby classes with specific blocks.
class SomeController < ApplicationController # extend and include extend SomeConcern include AnotherConcern # constants SOME_CONSTANT = '20'.freeze # callbacks before_action :authenticate_user! before_action :set_resource, only: :index before_action :authorize_resource!, only: :index before_action :set_requested_resource, only: :show before_action :authorize_requested_resource!, only: :show # public instance methods def index end def show end def new end def edit end def create end def update end def destroy end # protected methods protected def current_user @current_user = super || NullUser.new end # private methods private def set_resource end def authorize_resource! end def set_requested_resource end def authorize_requested_resource! end end
-
Use strong params to whitelist the list of parameters sent to the controller.
request.parameters[:user][:name]
params.require(:user).permit(:name)
When the strong params permit multiple attributes, prefer writing them in separate lines:
params.require(:user).permit(:name, :email, :phone, :address)
params.require(:user).permit( :address, :email, :name, :phone )
It serves the following purposes:
- Enhances readability.
- New changes inside the parameters are more visible.
Views
-
Use local variables instead of instance variables to pass data to views.
class UsersController def show @user = User.find(params[:id]) @user_presenter = UserPresenter.new(@user) render new end end
class UsersController def show user = User.find(params[:id]) user_presenter = UserPresenter.new(@user) render new, locals: { user: user, user_presenter: user_presenter } end end
-
Do not add presentation logic in views, use Presenters instead.
Project Structure
The structure is consistent with the conventional and built-in structure of Ruby on Rails – which is documented in the official Rails Guide – with the addition of unconventional directories (highlighted with *
):
app/
├── assets/
├── channels/
├── controllers/
├── decorators/ *
├── forms/ *
├── helpers/
├── javascript/
├── jobs/
├── mailers/
├── models/
├── policies/ *
├── presenters/ *
├── queries/ *
├── serializers/ *
├── services/ *
├── validators/ *
├── views/
bin/
config/
engines/ *
public/
storage/
vendor/
Decorators
-
Decorators are POROs abstracting view methods from models.
class User < ApplicationRecord def full_name "#{first_name} #{last_name}" end end
class User < ApplicationRecord delegate :full_name, to: :user_decorator def user_decorator @user_decorator ||= UserDecorator.new(self) end end class UserDecorator delegate :email, to: :user def initialize(user) @user = user end def full_name "#{user.first_name} #{user.last_name}" end private attr_reader :user end
-
SimpleDelegator
provides the means to delegate all public method calls to the object passed into the constructor. As all public methods of the object will be delegated, use it only in situations that require a high number of delegations.
class User < ApplicationRecord delegate :full_name, to: :user_decorator def user_decorator @user_decorator ||= UserDecorator.new(self) end end class UserDecorator < SimpleDelegator def full_name "#{first_name} #{last_name}" end end
-
Forms
-
Forms are POROs abstracting complex logic from controllers.
class CampaignEnrollingForm include ActiveModel::Model attr_accessor :user def initialize(user) @user = user end def save(params) ActiveRecord::Base.transaction do user.assign_attributes(params) user.charge! user.enroll! raise ActiveRecord::Rollback unless user.save end promote_errors(user.errors) errors.empty? end private def promote_errors(child_errors) child_errors.each do |attribute, message| errors.add(attribute, message) end end end
-
Include
ActiveModel::Model
to inherit attribute assignments and validations. -
Use CRUD-like operation to name public methods providing an ActiveRecord-like interface e.g.
save
,update
ordestroy
.
Policies
-
Policies are POROs handling authorization logic.
class CampaignPolicy < ApplicationPolicy def edit? user.completed_onboarding? end def update? user.completed_onboarding? end def manage? false end alias index? manage? alias show? manage? alias new? manage? alias create? manage? alias destroy? manage? end
-
Prefer using the gem pundit to implement authorization logic.
Presenters
Presenters are POROs abstracting view methods from models, controllers and views.
# app/views/users/show.html.slim
span.onboarding__message
- if user.onboarded?
= t('users.onboarding.welcome')
- else
= t('users.onboarding.welcome_back')
# app/views/users/show.html.slim
span.onboarding__message
= user_presenter.onboarding_message
# app/presenters/user_presenter.rb
class UserPresenter
attr_reader :user
def initialize(user)
@user = user
end
def onboarding_message
return I18n.t('users.onboarding.welcome') if user.onboarded?
I18n.t('users.onboarding.welcome_back')
end
end
Queries
-
Queries are POROs abstracting complex database fetching, sorting and ordering logic.
class UsersQuery attr_reader :users, :filters def initialize(users = User.all, filters = {}) @users = users @filters = filters end def call @users = date_filtered_users if filter_by_date.present? @users = users.includes(:location) end private def date_filtered_users users.where(created_at: filter_start_date..filter_end_date) end def filter_by_date filters[:start_date] || filters[:end_date] end def filter_start_date filters[:start_date].to_date.beginning_of_day rescue ArgumentError, NoMethodError start_of_time.beginning_of_day end def filter_end_date filters[:end_date].to_date.end_of_day rescue ArgumentError, NoMethodError Time.current.end_of_day end def start_of_time User.order(:created_at).limit(1).first.created_at end end
-
For complex data computation, use database views and materialized views. Prefer using the gem scenic for an efficient integration with
ActiveRecord::Migration
.
Serializers
-
Serializers are POROs handling the rendering of API responses.
class CampaignSerializer include JSONAPI::Serializer attributes :name, :year has_many :users belongs_to :account end
-
Prefer using the gem jsonapi-serializer to implement Ruby Objects serialization.
Services
Services are POROs handling business logic and the connections between the domain objects.
class CreateChargeService
def initialize(amount:, token:, **options)
@amount = amount
@token = token
@customer_id = options[:customer_id]
end
def call
raise Nimble::Errors::PaymentGatewayError, charge.failure_message.capitalize unless charge.paid
charge
rescue PaymentGateway::Error => e
raise Nimble::Errors::PaymentGatewayError, e
end
private
attr_reader :amount, :token, :customer_id
def charge
@charge ||= PaymentGateway::Charge.create(amount: charge_amount,
currency: Currency.default.iso_code.downcase,
card: token,
customer: customer_id)
end
end
Validators
Validators are ActiveModel-based validator classes extending ActiveModel validations. A validator handles the validation of a single concern but can be applied to multiple attributes as well.
-
ActiveModel::EachValidator
for validating individual attributes.
class FutureDateValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
record.errors.add(attribute, (options[:message] || :future_date)) unless in_future?(value)
end
private
def in_future?(date)
date.present? && Time.zone.parse(date.to_s).to_datetime > Time.zone.today
end
end
This now can be combined with the validates
method, and can be used to validate multiple attributes in a single line.
This can be used in multiple models as well.
class Payroll
include ActiveModel::Validations
validates :pay_date, presence: true, future_date: true
end
-
ActiveModel::Validator
class MyComplexValidator < ActiveModel::Validator
def validate(record)
if some_complex_logic
record.errors.add(:base, :invalid)
end
end
private
def some_complex_logic
# ...
end
end
This now can be used with validates_with
class Payroll
include ActiveModel::Validations
validates_with MyComplexValidator
end
Engines
Engines are built-in into Ruby on Rails; even the Rails::Application
inherits from Rails::Engine
. The team uses engines to split large monolithic applications into sub-sets of mini-applications. The main benefits:
-
-
app
folder. -
-
checkout
) but only 1 instance for another engine (e.g.admin
) by selectively mounting engines on each instance.
Rails.application.routes.draw do
mount ProjectNameAdmin::Engine, at: '/', if: ENGINE_ADMIN_ENABLED
mount ProjectNameCheckout::Engine, at: '/', if: ENGINE_CHECKOUT_ENABLED
end
-
Use the
kebab-case
project name as a namespace for all engines for modularization purposes.engines/ ├── admin/ ├── checkout/ ├── settings/ ├── website/
engines/ ├── project-name_admin/ ├── project-name_checkout/ ├── project-name_setting/ ├── project-name_website/
-
Use singular to name engines.
engines/ ├── project-name_campaigns/ ├── project-name_profiles/ ├── project-name_settings/
engines/ ├── project-name_campaign/ ├── project-name_profile/ ├── project-name_setting/
-
Store shared domain objects – typically almost all models and policies – and functionality – typically stylesheets and JavaScript – into the main app directory.
/app
becomes thecore
application. Each engine has its own specific models, policies and assets.app/ ├── assets/ │ └── stylesheets/ │ └── core.scss ├── controllers/ ├── helpers/ │ └── svg_helper.rb ├── javascript/ │ └── core.js ├── jobs/ ├── mailers/ ├── models/ │ ├── campaign.rb │ ├── ... │ └── user.rb ├── policies/ * │ ├── campaign_policy.rb │ ├── ... │ └── user_policy.rb bin/ config/ engines/ ├── project-name_admin/ ├── project-name_checkout/ ├── project-name_settings/ ├── project-name_website/ public/ storage/ vendor/
-
Define only shared gems in the root
Gemfile
. If a gem is used in only one engine, it must be defined in the engine’sgemspec
file.# In Gemfile source 'https://rubygems.org' ruby '2.5.3' # Backend gem 'rails', '6.0.0' # Latest stable. gem 'puma' # Use Puma as the app server. # Authorizations gem 'pundit' # Minimal authorization through OO design and pure Ruby classes. # In engines/project-name_auth/project-name_auth.gemspec Gem::Specification.new do |s| # ... s.add_dependency 'devise' s.add_dependency 'omniauth' s.add_dependency 'omniauth-facebook' s.add_dependency 'omniauth-twitter' end
-
Define all engines in the root
Gemfile
and into a group namedengines
.gem 'project-name_admin', path: 'engines/project-name_admin' gem 'project-name_checkout', path: 'engines/project-name_checkout'
group :engines do gem 'project-name_admin', path: 'engines/project-name_admin' gem 'project-name_checkout', path: 'engines/project-name_checkout' end
This group can easily be required in
application.rb
:require_relative 'boot' require 'rails/all' Bundler.require(:engines, *Rails.groups)
-
If a middleware is used in only one engine, it must be defined in the engine’s
engine
file withmiddleware.use
instead ofapp.middleware.use
module ProjectNameAdmin class Engine < ::Rails::Engine isolate_namespace ProjetNameAdmin initializer 'project-name_admin.add_middleware' do |app| app.middleware.use CustomMiddleware end end end
module ProjectNameAdmin class Engine < ::Rails::Engine isolate_namespace ProjetNameAdmin initializer 'project-name_admin.add_middleware' do middleware.use CustomMiddleware end end end
-
Prefer placing all specs in the root
/spec
directory instead of inside the engine. The main rationale is that all specs are run together and share the same setup and configuration (/spec/support
).engines/ ├── project-name_admin/ │ └── spec/ * ├── project-name_checkout/ │ └── spec/ *
engines/ ├── project-name_admin/ ├── project-name_checkout/ spec/ ├── controllers/ │ └── project-name_admin/ * │ └── *_spec.rb │ └── project-name_checkout/ * │ └── *_spec.rb
Delegations
The delegate module allows to easily expose contained objects’ public methods.
-
Use prefixes when the name of the delegated attribute is not explicit enough.
class User attr_reader :name belongs_to :company, inverse_of: :users # user.company_name delegate :name, to: :company, prefix: true end
-
Use the
private
option when the delegation should be private.class User attr_reader :name belongs_to :company, inverse_of: :users delegate :tax_code, to: :company, private: true end
class User attr_reader :name belongs_to :company, inverse_of: :users private # This delegation is still public! delegate :tax_code, to: :company end
Database
-
schema.rb
file as it is faster and does not rely on the migration files.db/setup: rails db:prepare # Will only load the schema if the DB does not exist
Use explicit steps to load the DB from the
schema.rb
file:db/setup: rails db:create # creates the database if it does not exist rails db:schema:load # creates tables and columns within the existing database following schema.rb. This will delete existing data. rails db:migrate # runs migrations that have not run yet.
-
Avoid calling application functions within migration files.
class AddLocaleToUsers < ActiveRecord::Migration[7.0] def change add_column :users, :locale, :string Locales.update_users_locale end end
-
SQL commands or rake tasks are preferred options for data migrations based on the data complexity.
Using SQL commands is the preferred option if the migrating data are simple such as fixed value or current datetime.
class AddLocaleToUsers < ActiveRecord::Migration[7.0] def change add_column :users, :locale, :string reversible do |dir| dir.up do execute <<-SQL UPDATE Users SET locale='EN' SQL end end end end
If the migrating data are complex, prefer creating rake tasks, and running them after the changes are deployed.
# awesome-web/lib/tasks/locale.rake namespace :locale do desc 'Set users locale' task import: :environment do Locales.update_users_locale end end
Documentation
Use YARD docblock to document public methods.
module PaymentGateway
module V1
class Account < Resources::BaseResource
# @param [String] access_token
# @param [Hash] args
# @option args [Integer] :version
# @return [Object]
def retrieve_identification(access_token, args)
#...
end
end
end
end