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 to pluralize based on the intent of the table.

    # Given these two models
    class Campaign < ApplicationRecord
      has_many :influencers, inverse_of: :campaign
    end
    
    class Influencer < ApplicationRecord
      has_many :campaigns, inverse_of: :influencer
    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
      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
    

    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 inverse_of option when defining associations to prevent avoidable SQL queries and keep models in sync.

    # Bad
    class Author < ApplicationRecord
      has_many :books
    end
    
    class Book < ApplicationRecord
      belongs_to :author
    end
    
    # Good
    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 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.
  • Prefer using find_each with batch_size to process large amount of records.

    # Bad
    User.all.each do |user|
      NewsLetter.deliver(user)
    end
    
    # Good
    User.find_each(batch_size: 500).each do |user|
      NewsLetter.deliver(user)
    end      
    
  • Prefer using exists? over present? to check if a record exists.

    # Bad
    User.where(email: '[email protected]').present?
    
    # Good
    User.exists?(email: '[email protected]')
    
  • Prefer using pluck to get some model attributes as an array.

    # Bad
    active_user_emails = User.where(status: 'active').map(&:email)
    
    # Good
    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.

    # Bad
    active_user_emails = User.where(status: 'active')
      
    # Good
    active_user_emails = User.where(status: 'active').select(:id, :name, :email)
    
  • Prefer using insert_all to create many records at once.

    # Bad
    users = [
      { name: 'john', email: '[email protected]' },
      { name: 'ricky', email: '[email protected]' },
      { name: 'maria', email: '[email protected]' }
    ]
    
    users.each do |user|
      User.create(user)
    end
    
    # Good
    users = [
      { name: 'john', email: '[email protected]' },
      { name: 'ricky', email: '[email protected]' },
      { name: 'maria', email: '[email protected]' }
    ]
    
    User.insert_all(users)
    

Routes

  • Prefer using only over except to be more explicit about what routes are available.

    # Bad
    resources :configurations, except: [:new, :create, :destroy]
    
    # Good
    resources :configurations, only: [:index, :show, :edit, :update]
    
  • Prefer using concern to have reusable routes.

    # Bad
    resources :articles do
      resource :comments
    end
      
    resources :photos do
      resources :comments
    end
    
    # Good
    concern :commentable do
      resources :comments
    end
    
    resources :articles, concerns: :commentable
    resources :photos, concerns: :commentable
    
  • Prefer using shallow nesting to avoid deep nested routes.

    # Bad
    resources :articles do
      resources :comments, only: [:index, :new, :create]
    end
    
    resources :comments, only: [:show, :edit, :update, :destroy]
    
    # Good
    resources :articles do
      resources :comments, shallow: true
    end
    
  • Prefer using constraints to validate request 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.

    # Bad
    request.parameters[:user][:name]
    
    # Good
    params.require(:user).permit(:name)
    
  • Do not add business logic in controllers, use Forms, Queries or Services instead.

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
    
    # Better
    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
    
  • SimpleDelegator provides the means to delegate all supported method calls to the object passed into the constructor.

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)
    
  • If a middleware is used in only one engine, it must be defined in the engine’s engine file with middleware.use instead of app.middleware.use

    # Bad
    module ProjectNameAdmin
      class Engine < ::Rails::Engine
        isolate_namespace ProjetNameAdmin
    
        initializer 'project-name_admin.add_middleware' do |app|
          app.middleware.use CustomMiddleware
        end
      end
    end
    
    # Good
    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 inside of 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 :users, :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
    
  • Define both default value and NOT NULL constraint for boolean columns to prevent the three-state boolean problem.

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

  • Name the migration files more elaborately. For table migration, Suppose you add/drop any column to/from a table then, keep both column_name and table_name in migration name. The name of the migration name will like DropSurnameFromUsers. Or, for multiple migration together, just concat the column names and table names in the class name. So the general template is [ChangeMade]+To/From+[TableNamePlural]

    # Bad
    class DropSurnameColumn < ActiveRecord::Migration[6.0]
      def change
        remove_column :users, :surname
      end
    end
    
    # Good
    class DropSurnameFromUsers < ActiveRecord::Migration[6.0]
      def change
        remove_column :users, :surname
      end
    end
    
    class DropSurnameNicknameFromUsers < ActiveRecord::Migration[6.0]
      def change
        remove_column :users, :surname
        remove_column :users, :nickname
      end
    end
    
  • For data migration, use meaningful name also.

    # Bad
    class UpdateWebsite < ActiveRecord::Migration[6.0]
      def change
        # your changes to DB here
      end
    end
    
    # Good
    class UpdateWebsiteColumnOfUsers < ActiveRecord::Migration[6.0]
      def change
        # your changes to DB here
      end
    end
    

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