Ruby on Rails πŸš‡

Hero image for Ruby on Rails πŸš‡

All Ruby conventions apply to Ruby on Rails but working with a framework adds another superset of conventions to follow.

PORO stands for Plain Old Ruby Objects and is used to differentiate between objects inheriting functionality from Ruby-on-Rails and those who do not (the latter are POROs).

Naming

  • Suffix each project name with -web or -api depending on the project type.
# Bad
rails new project-name
rails new project-name --api

# Good
rails new project-name-web
rails new project-name-api --api

Use the team template for all Ruby on Rails projects.

  • Use snake_case for both database tables and columns. But use plural for database tables and singular for column names.
+---------------------------+
| campaign_locations        |
+-------------+-------------+
| id          | ID          |
| name        | STRING      |
| location_id | FOREIGN KEY |
| updated_at  | DATETIME    |
+-------------+-------------+
  • Combine table names to name join tables choosing pluralizing based on the intent of the table.
# Given these two models 
class Campaign < ApplicationRecord
  has_many :influencers
end

class Influencer < ApplicationRecord
  has_many :campaigns
end

# With the resulting join table 

# Bad
class Campaign < ApplicationRecord
  has_many :influencers, through: :campaigns_influencers
  has_many :campaigns_influencers, inverse_of: :campaign
end

class Influencer < ApplicationRecord
  has_many :campaigns, through: :campaigns_influencers
end 

# Good
class Campaign < ApplicationRecord
  has_many :influencers, through: :campaign_influencers
  has_many :campaign_influencers, inverse_of: :campaign
end

class Influencer < ApplicationRecord
  has_many :campaigns, through: :campaign_influencers
end
  • Use predicate-like name for boolean database columns.
# Bad
add_column :users, :enabled, :boolean, null: false, index: true  

# Good
add_column :users, :is_enabled, :boolean, null: false, index: true  
  • Use kebab-case for naming assets files.
# Bad
app/assets/images/user_avatar.svg
app/assets/stylesheets/components/_card_pricing.scss
public/assets/images/error_404.svg

# Good
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.
# Bad
engines/campaign/
lib/errors/

# Good
engines/project-name_campaign/
lib/project-name/errors/

Syntax

  • Use Time.current instead of Time.now to use the timezone configured in the Rails environment.

Configuration

  • Use fetch to access environment variables.
# Bad
config.action_mailer.default_url_options = {
  host: ENV['DEFAULT_HOST'],
  port: ENV['DEFAULT_PORT']
}

# Good
config.action_mailer.default_url_options = {
  host: ENV.fetch('DEFAULT_HOST'),
  port: ENV.fetch('DEFAULT_PORT')
}
  • Provide a default value for non-sensitive variable 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 as project-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
  def http_basic_authenticate
    authenticate_or_request_with_http_basic do |username, password|
      username == BASIC_AUTHENTICATION_USERNAME && password == BASIC_AUTHENTICATION_PASSWORD
    end
  end
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
  belongs_to :organization

  # delegations
  delegate :full_name, to: :model_decorator
 
  # validations
  validates :name, presence: true
  validate :custom_validator

  # 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

When dealing with PORO models, then the default structure for Ruby classes applies.

  • Define explicitly enum keys to avoid both data and implementation inconsistencies. The latter is often caused when non-positive values are needed.
# Bad
enum status: [:inactive, :active, :blacklisted]

# Good
enum status: { inactive: 0, active: 1, blacklisted: 2 }
enum status: { inactive: -1, active: 0, blacklisted: 1 }
  • Use validates with options instead of convenience methods such as validates_option_name_of.
# Bad
validates_presence_of :name

# Good
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:

      # Bad
      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
        
      # Good
      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 using default_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.

  • Use the gem Bullet to detect n+1 queries.

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.
# Bad
request.parameters[:user][:name]

# Good
params.require(:user).permit(:name)

Views

  • Use local variables instead of instance variables to pass data to views.
# Bad
class UsersController
  def show
    @user = User.find(params[:id])
    @user_presenter = UserPresenter.new(@user)

    render new
  end
end

# Good
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.

# Bad
class User < ApplicationRecord
  def full_name
    "#{first_name} #{last_name}"
  end
end

# Good
class User < ApplicationRecord
  delegate full_name, to: user_decorator

  def user_decorator
     @user_decorator ||= UserDecorator.new(self)
  end
end

class UserDecorator
  attr_reader :user

  def initialize(user)
    @user = user
  end
 
  def full_name
    "#{user.first_name} #{user.last_name}"
  end
end

Decorators logic is meant to be used solely in backend-related areas e.g. other models, asynchronous jobs, form objects, services… When in need to format data for a view, use a presenter instead.

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 attributes assignments and validations.

  • Use CRUD-like operation to name public methods providing an ActiveRecord-like interface e.g. save, update or destroy.

Read a detailed overview of Form Objects on our blog πŸš€

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.

# Bad
# app/views/users/show.html.slim  
span.onboarding__message
  - if user.onboarded?
    = t('users.onboarding.welcome')
  - else
    = t('users.onboarding.welcome_back')

# Good
# 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

Presenters logic is meant to be used solely in views-related areas e.g. web views, mailers, printed views. When in need to format data for backend purposes, use a decorator instead.

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 API responses rendering.
class CampaignSerializer
  include FastJsonapi::ObjectSerializer

  attributes :name, :year

  has_many :users
  belongs_to :account
end
  • Prefer using the gem fast_jsonapi 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.

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

Engines

Engines are built-in into Ruby on Rails; even the Rails::Application inherit from Rails::Engine. We use engines to split large monolithic applications into sub-sets of mini-applications. The main benefits:

  • Separation of concerns with clear boundary definitions between domain objects. Even asset manifest files (CSS and JS) are split resulting into better performance and easier maintenance with direct dependencies.
  • Force developers to architect/design the domain objects instead of grouping everything into one main app folder.
  • Easier management of functionality β€œgrowth” over time. The addition of a new feature becomes a matter of deciding in which engine it fits or in rare cases if it requires a new engine.
  • Initial built-in horizontal scalability as each engine can be mounted independently. For instance, it’s possible to run 2 instances for a specific engine (e.g. checkout) but only 1 instance for another engine (e.g. admin) by mounting selectively 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

Engines are not a silver bullet and comes with additional initial architecture and setup costs. So engine-based architecture should be preferred for medium to large applications. For small applications (< 3-5 models and controllers), the conventional architecture – placing all code in app/ – can be sufficient.

  • Use the kebab-case project name as a namespace for all engines for modularization purposes.
// Bad
engines/
β”œβ”€β”€ admin/
β”œβ”€β”€ checkout/
β”œβ”€β”€ settings/
β”œβ”€β”€ website/

// Good
engines/
β”œβ”€β”€ project-name_admin/
β”œβ”€β”€ project-name_checkout/
β”œβ”€β”€ project-name_setting/
β”œβ”€β”€ project-name_website/

Make sure to configure the namespace in the engine:

module ProjectNameCheckout
  class Engine < ::Rails::Engine
    isolate_namespace ProjetNameCheckout
  end
end
  • Use singular to name engines.
// Bad
engines/
β”œβ”€β”€ project-name_campaigns/
β”œβ”€β”€ project-name_profiles/
β”œβ”€β”€ project-name_settings/

// Good
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 the core 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’s gemspec 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 named engines.
# Bad
gem 'project-name_admin', path: 'engines/project-name_admin'
gem 'project-name_checkout', path: 'engines/project-name_checkout' 

# Good
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)
  • Prefer placing all specs in the root /spec directory instead inside the engine. The main rationale is that all specs are run together and share the same setup and configuration (/spec/support).
// Bad
engines/
β”œβ”€β”€ project-name_admin/
β”‚Β Β  └── spec/ *
β”œβ”€β”€ project-name_checkout/
β”‚Β Β  └── spec/ *

// Good
engines/
β”œβ”€β”€ project-name_admin/
β”œβ”€β”€ project-name_checkout/
spec/
β”œβ”€β”€ controllers/
β”‚Β Β  └── project-name_admin/ *
β”‚Β Β   Β Β  └── *_spec.rb
β”‚Β Β  └── project-name_checkout/ *
β”‚Β Β   Β Β  └── *_spec.rb

Database

Relational Database

  • Prefer using PostgreSQL for relational data storage.

  • Use database indexes on foreign keys and boolean columns for faster queries.

add_column :locations, :location_id, :integer, foreign_key: true, index: true
add_column :users, :is_enabled, :boolean, null: false, default: false, index: true 
  • Use database constraints such as null and default along with model validations.
# Given this model  
class User < ApplicationRecord
  validates :name, presence: true
  validates :username, presence: true, uniqueness: true
  validates :encrypted_password, presence: true
end 

# Bad
create_table 'users', force: :cascade do |t|
  t.name :string
  t.username :string, index: true
  t.encrypted_password :string

  t.timestamps
end

# Good
create_table 'users', force: :cascade do |t|
  t.name :string, null: false
  t.username :string, null: false, index: true
  t.encrypted_password :string, null: false

  t.timestamps

  t.index :username, unique: true
end
# Bad
add_column :users, :is_enabled, :boolean, index: true 

# Good
add_column :users, :is_enabled, :boolean, null: false, default: false, index: true 
  • Use bigint or uuid data type column for storing primary IDs.

  • Prefer citext data type column for storing case insensitive data such as emails.

  • Prefer jsonb data type column for storing object-like data over hstore and json. Settings-like or configuration-like data are good candidates to be stored as jsonb.

# schema.rb
create_table "campaigns", force: :cascade do |t|
  t.string "name", null: false
  t.decimal "budget_amount", precision: 19, scale: 2, default: "0.0"
  t.jsonb "platforms"
  t.bigint "user_id", null: false, index: true
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end 

# app/models/campaigns
class Campaign < ApplicationRecord
  store_accessor :platforms, %i[facebook linkedin twitter]
  
  belongs_to :advertiser, inverse_of: :campaigns, foreign_key: 'user_id'
end
  • Always soft-delete relational data for integrity and audit purposes. Gems such as paranoia and discard provide ready-to-use solutions.

Non-relational Database

  • Prefer using Redis for key/value data storage.

  • Prefer using ElasticSearch for document-oriented data storage.

Security

  • Use prepared statements for database operations requiring user inputs to prevent SQL injections.
# Bad
User.where("last_seen_at > #{params[:start_datetime]}")

# Good
User.where('last_seen_at > ?', params[:start_datetime])
  • Use hash-based parameters for database operations requiring constants.
# Bad
User.where('status = ?', :new)

# Good
User.where(status: :active)
  • Beware of initialing objects directly based on user input. Always add a validation layer or even prefer using factories.
class FooForm; end
class BarForm; end

# Bad
form_klass = "#{params[:kind].camelize}Form".constantize
form_klass.new.submit(params)

# Good
klasses = {
  'foo' => FooForm,
  'bar' => BarForm
}

klass = klasses[params[:kind]]
if klass
  klass.new.submit(params)
end

# Best
class FormFactory
  def self.build(type, *args)
    case type
    when :foo
      FooForm.new(*args)
    when :bar
      BarForm.new(*args)
    end
  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