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.

    rails new project-name
    rails new project-name --api
    
    rails new project-name-web
    rails new project-name-api --api
    

    Use the team template for all Ruby on Rails projects.

  • 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

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

Application Architecture

To implement a new feature, developers must decide when to leverage the standard Rails way and when to use non-standard ways instead:

  • The Rails way means using Rails’ built-in DSL and implicit conventions.
  • The non-standard Rails way means using small Plain Old Ruby Objects (POROs), resulting in custom and more explicit implementations, e.g., Non-ActiveRecord Models, form objects.

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.

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

    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 as validates_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 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.

    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? over present? 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
    

    This convention is only applicable for Rails 7.0 or later.

Routes

  • Prefer using only over except 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 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.

    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:

    1. Enhances readability.
    2. New changes inside the parameters are more visible.
  • 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.

    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
    

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 the Nimble 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.

# 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

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

  • Prefer using 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
  • In case a custom validator contains complex logic, consider moving it to a separate class inheriting from 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 inherit from Rails::Engine. The team uses 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.

    engines/
    ├── admin/
    ├── checkout/
    ├── settings/
    ├── website/
    
    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.

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

    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 with middleware.use instead of app.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 inside of 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

All practices on the Web - Database page apply to Rails projects. The following section brings practices and recommendations specific to Rails projects.

  • Load the database using the 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.
    

    The docker configuration of Nimble’s Rails template creates the database when the container is built. As a consequence, The db:prepare task will not run the schema load, but only run the db migrations. At best it is slower, at worst it will raise some errors on Ruby on Rails < 7.1.0. For applications running on 7.1.0 and above, the issue should be fixed, see this blog article for more insights.

  • 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 the data migration 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