All topics
Backend · Learning hub

Rails notes for developers

Master Rails with a curated set of 4 developer notes — core concepts, patterns, and interview prep. Maintained by the DevRecall team.

Save this stack to your DevRecallMore Backend notes
Rails

MVC, Routes & Controllers

Rails: MVC, Routes & Controllers Ruby on Rails is an opinionated MVC web framework. Convention over configuration means predictable structure: routes map URLs t

Rails: MVC, Routes & Controllers

Ruby on Rails is an opinionated MVC web framework. Convention over configuration means predictable structure: routes map URLs to controller actions, controllers coordinate models and views. Rails 7+ uses import maps or Vite for JS.

Getting Started

# Install Rails
gem install rails

# New app
rails new myapp --database=postgresql    # PostgreSQL
rails new myapp --api                    # API-only mode (no views)
rails new myapp --database=postgresql --css=tailwind

cd myapp
rails db:create
rails server   # http://localhost:3000

# Generators
rails generate model User name:string email:string:uniq
rails generate controller Users index show
rails generate scaffold Post title:string body:text user:references
rails generate migration AddAgeToUsers age:integer
rails db:migrate
rails routes   # list all routes

Routes

# config/routes.rb
Rails.application.routes.draw do
  root 'home#index'

  # RESTful resources — generates 7 standard routes
  resources :posts                         # index, show, new, create, edit, update, destroy
  resources :posts, only: [:index, :show]  # limit actions
  resources :posts, except: [:destroy]

  # Nested resources
  resources :users do
    resources :posts, only: [:index, :create]   # /users/:user_id/posts
    member  { post :activate }                  # POST /users/:id/activate
    collection { get :search }                  # GET /users/search
  end

  # Namespace
  namespace :admin do
    resources :users                        # /admin/users → Admin::UsersController
    root 'dashboard#index'
  end

  # API versioning
  namespace :api do
    namespace :v1 do
      resources :posts, only: [:index, :show, :create]
    end
  end

  # Custom routes
  get  '/login',  to: 'sessions#new',    as: :login
  post '/login',  to: 'sessions#create'
  delete '/logout', to: 'sessions#destroy', as: :logout

  # Constraints
  get '/users/:id', to: 'users#show', constraints: { id: /d+/ }
end

Controllers

class PostsController < ApplicationController
  before_action :authenticate_user!
  before_action :set_post, only: [:show, :edit, :update, :destroy]

  def index
    @posts = Post.published
                 .includes(:author, :tags)   # eager load
                 .order(created_at: :desc)
                 .page(params[:page]).per(20)

    respond_to do |format|
      format.html
      format.json { render json: @posts }
    end
  end

  def show; end   # @post set by before_action

  def new
    @post = Post.new
  end

  def create
    @post = current_user.posts.build(post_params)

    if @post.save
      redirect_to @post, notice: 'Post was successfully created.'
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @post.update(post_params)
      redirect_to @post, notice: 'Post updated.'
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @post.destroy
    redirect_to posts_url, status: :see_other, notice: 'Post deleted.'
  end

  private

  def set_post
    @post = Post.find(params[:id])
  end

  def post_params
    params.require(:post).permit(:title, :body, :published_at, tag_ids: [])
  end
end

Filters & Flash

class ApplicationController < ActionController::Base
  before_action :set_locale
  around_action :log_request_duration

  rescue_from ActiveRecord::RecordNotFound, with: :not_found
  rescue_from Pundit::NotAuthorizedError, with: :forbidden

  private

  def authenticate_user!
    redirect_to login_path unless current_user
  end

  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end

  def not_found
    render :not_found, status: :not_found
  end

  def forbidden
    render :forbidden, status: :forbidden
  end

  def log_request_duration
    start = Time.now
    yield
    logger.info "Request took #{(Time.now - start).round(3)}s"
  end
end

# Flash messages — survive exactly one redirect
redirect_to @post, notice: 'Created!'   # flash[:notice]
redirect_to root_path, alert: 'Error!'  # flash[:alert]
flash.now[:notice] = 'Saved'            # only for current render (no redirect)
Rails

ActiveRecord: Models, Associations & Queries

Rails: ActiveRecord Model & Validations class Post < ApplicationRecord # Associations belongs_to :author, class_name: 'User' has_many :comments, dependent: :des

Rails: ActiveRecord

Model & Validations

class Post < ApplicationRecord
  # Associations
  belongs_to :author, class_name: 'User'
  has_many   :comments, dependent: :destroy
  has_many   :commenters, through: :comments, source: :user
  has_one    :featured_image, dependent: :destroy
  has_and_belongs_to_many :tags
  has_many_attached :images          # Active Storage

  # Validations
  validates :title, presence: true, length: { minimum: 5, maximum: 200 }
  validates :body,  presence: true, length: { minimum: 50 }
  validates :slug,  presence: true, uniqueness: true,
                    format: { with: /A[a-z0-9-]+z/, message: 'only lowercase letters, numbers, hyphens' }
  validates :status, inclusion: { in: %w[draft published archived] }
  validate  :published_at_in_the_past, if: :published?

  # Enum
  enum status: { draft: 0, published: 1, archived: 2 }, _prefix: true
  # post.status_published?, post.status_published!, post.status

  # Callbacks
  before_validation :generate_slug, if: -> { slug.blank? }
  after_create :notify_subscribers
  before_destroy :check_no_active_comments

  # Scopes
  scope :published,  -> { where(status: :published) }
  scope :recent,     -> { order(created_at: :desc) }
  scope :by_author,  ->(user) { where(author: user) }
  scope :search,     ->(term) { where('title ILIKE ? OR body ILIKE ?', "%#{term}%", "%#{term}%") }
  scope :with_images,-> { joins(:images_attachments) }

  private

  def generate_slug
    self.slug = title.parameterize
  end

  def published_at_in_the_past
    errors.add(:published_at, "can't be in the future") if published_at&.future?
  end
end

Queries & the Query Interface

# Basic finders
Post.all
Post.find(1)
Post.find_by(slug: 'hello-world')       # nil if not found
Post.find_by!(slug: 'hello-world')      # raises if not found
Post.where(status: :published)
Post.where('created_at > ?', 1.week.ago)
Post.where(author_id: [1, 2, 3])       # IN clause
Post.where.not(status: :archived)

# Ordering, limiting, offsetting
Post.order(:created_at)                 # ASC
Post.order(created_at: :desc)
Post.order('LOWER(title) ASC')         # raw SQL
Post.limit(10)
Post.offset(20).limit(10)             # page 3 (10 per page)

# Joins and eager loading
Post.joins(:author)                     # INNER JOIN (filters)
Post.joins(:comments).distinct          # posts with at least one comment
Post.includes(:author, :tags)           # eager load (avoids N+1)
Post.eager_load(:comments)             # always uses LEFT JOIN (filterable)
Post.preload(:author)                  # always uses separate query

# Selecting and plucking
Post.select(:id, :title, :created_at)
Post.pluck(:title)                     # ["Title 1", "Title 2"] — no model objects
Post.pluck(:id, :title)               # [[1, "T1"], [2, "T2"]]
Post.pick(:title)                     # single value (first match)

# Aggregations
Post.count
Post.where(status: :published).count
Post.maximum(:view_count)
Post.minimum(:created_at)
Post.average(:rating)
Post.sum(:view_count)
Post.group(:status).count            # {"draft"=>5, "published"=>42}

# Updating
post.update(title: 'New Title')      # save with validations
post.update_column(:view_count, 10)  # skip validations, run callbacks
Post.where(status: :draft).update_all(archived: true)  # bulk update

# Destroying
post.destroy                          # triggers callbacks, destroys associations
post.delete                           # SQL DELETE, no callbacks
Post.where(created_at: ..1.year.ago).destroy_all

Migrations

# db/migrate/20240501000000_create_posts.rb
class CreatePosts < ActiveRecord::Migration[7.2]
  def change
    create_table :posts do |t|
      t.string  :title, null: false
      t.text    :body
      t.string  :slug, null: false
      t.integer :status, default: 0, null: false
      t.datetime :published_at
      t.references :author, null: false, foreign_key: { to_table: :users }

      t.timestamps
    end

    add_index :posts, :slug, unique: true
    add_index :posts, :status
    add_index :posts, :published_at
    add_index :posts, [:author_id, :status]  # composite
  end
end

# Common migration helpers
add_column    :users, :age, :integer
remove_column :users, :legacy_field
rename_column :users, :username, :login
change_column_null :users, :email, false
add_reference :posts, :category, foreign_key: true
add_index     :posts, :title

# Reversible data migration
class BackfillUserSlugs < ActiveRecord::Migration[7.2]
  def up
    User.find_each { |u| u.update_column(:slug, u.name.parameterize) }
  end

  def down
    User.update_all(slug: nil)
  end
end
Rails

Views, APIs & Asset Pipeline

Rails: Views, APIs & Assets ERB Views & Partials <%# app/views/posts/show.html.erb %> <article> <h1><%= @post.title %></h1> <p class="text-muted"> By <%= link_t

Rails: Views, APIs & Assets

ERB Views & Partials

<%# app/views/posts/show.html.erb %>
<article>
  <h1><%= @post.title %></h1>
  <p class="text-muted">
    By <%= link_to @post.author.name, user_path(@post.author) %> &middot;
    <%= time_tag @post.published_at, format: :long %>
  </p>

  <%= @post.body %>

  <%# Partial with local variable %>
  <%= render 'shared/tag_list', tags: @post.tags %>

  <%# Collection partial — renders _comment.html.erb for each %>
  <%= render @post.comments %>
</article>

<%# app/views/posts/_post.html.erb (collection partial) %>
<div class="post" id="<%= dom_id(post) %>">
  <h2><%= link_to post.title, post %></h2>
  <p><%= truncate(post.body, length: 200) %></p>
</div>

<%# Layouts — app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
<head>
  <title><%= content_for?(:title) ? yield(:title) : "MyApp" %></title>
  <%= csrf_meta_tags %>
  <%= stylesheet_link_tag :app, data: { turbo_track: "reload" } %>
</head>
<body>
  <nav><%= render 'shared/navbar' %></nav>
  <main><%= yield %></main>
</body>
</html>

Rails API Mode & JSON Responses

# API-only controller (rails new myapp --api)
class Api::V1::PostsController < ApplicationController
  def index
    posts = Post.published.includes(:author).recent.page(params[:page])

    render json: {
      data: posts.map { |p| post_json(p) },
      meta: {
        total: posts.total_count,
        page:  posts.current_page,
        pages: posts.total_pages,
      }
    }
  end

  def show
    post = Post.find(params[:id])
    render json: { data: post_json(post) }
  end

  def create
    post = current_user.posts.build(post_params)
    if post.save
      render json: { data: post_json(post) }, status: :created
    else
      render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
    end
  end

  private

  def post_json(post)
    {
      id: post.id, title: post.title, slug: post.slug,
      author: { id: post.author.id, name: post.author.name },
      created_at: post.created_at.iso8601,
    }
  end
end

# Or use jbuilder gem for view-based JSON templates
# app/views/api/v1/posts/show.json.jbuilder
# json.data do
#   json.id   @post.id
#   json.title @post.title
# end

Helpers, Concerns & Service Objects

# app/helpers/posts_helper.rb
module PostsHelper
  def reading_time(text)
    words = text.split.size
    minutes = (words / 200.0).ceil
    "#{minutes} min read"
  end
end

# app/controllers/concerns/authenticatable.rb
module Authenticatable
  extend ActiveSupport::Concern

  included do
    before_action :authenticate_user!
    helper_method :current_user, :user_signed_in?
  end

  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end

  def user_signed_in?
    current_user.present?
  end

  def authenticate_user!
    redirect_to login_path unless user_signed_in?
  end
end

# app/models/concerns/sluggable.rb
module Sluggable
  extend ActiveSupport::Concern

  included do
    before_validation :generate_slug
    validates :slug, presence: true, uniqueness: true
  end

  private

  def generate_slug
    self.slug ||= title.parameterize
  end
end

class Post < ApplicationRecord
  include Sluggable
end
Rails

Testing, Jobs & Deployment

Rails: Testing, Background Jobs & Deployment Testing with RSpec & FactoryBot # spec/factories/users.rb FactoryBot.define do factory :user do name { Faker::Name.

Rails: Testing, Background Jobs & Deployment

Testing with RSpec & FactoryBot

# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    name  { Faker::Name.full_name }
    email { Faker::Internet.unique.email }
    password { 'password123' }

    trait :admin do
      role { 'admin' }
    end

    trait :with_posts do
      after(:create) { |user| create_list(:post, 3, author: user) }
    end
  end
end

# spec/models/post_spec.rb
RSpec.describe Post, type: :model do
  let(:post) { build(:post) }

  describe 'validations' do
    it { is_expected.to validate_presence_of(:title) }
    it { is_expected.to validate_length_of(:title).is_at_least(5) }
    it { is_expected.to belong_to(:author) }
    it { is_expected.to have_many(:comments).dependent(:destroy) }
  end

  describe '#generate_slug' do
    it 'creates a slug from the title' do
      post = create(:post, title: 'Hello World')
      expect(post.slug).to eq('hello-world')
    end
  end
end

# spec/requests/api/v1/posts_spec.rb (request specs — preferred for APIs)
RSpec.describe 'Api::V1::Posts', type: :request do
  let(:user) { create(:user) }
  let(:headers) { { 'Authorization' => "Bearer #{generate_token(user)}" } }

  describe 'GET /api/v1/posts' do
    before { create_list(:post, 3, :published, author: user) }

    it 'returns published posts' do
      get api_v1_posts_path, headers: headers
      expect(response).to have_http_status(:ok)
      json = response.parsed_body
      expect(json['data'].size).to eq(3)
    end
  end
end

Active Job & Sidekiq

# config/application.rb
config.active_job.queue_adapter = :sidekiq

# app/jobs/send_welcome_email_job.rb
class SendWelcomeEmailJob < ApplicationJob
  queue_as :default
  retry_on Net::SMTPError, wait: :polynomially_longer, attempts: 5
  discard_on ActiveRecord::RecordNotFound

  def perform(user_id)
    user = User.find(user_id)
    UserMailer.welcome(user).deliver_now
  end
end

# Enqueue
SendWelcomeEmailJob.perform_later(user.id)
SendWelcomeEmailJob.set(wait: 1.hour).perform_later(user.id)

# config/sidekiq.yml
# :queues:
#   - [critical, 3]
#   - [default, 2]
#   - [mailers, 1]
#   - [low, 1]
# Mount dashboard in routes.rb:
# require 'sidekiq/web'
# mount Sidekiq::Web => '/sidekiq'

Deployment

# Heroku
heroku create myapp
heroku addons:create heroku-postgresql
heroku config:set RAILS_MASTER_KEY=$(cat config/master.key)
git push heroku main
heroku run rails db:migrate

# Kamal (Rails 8 default — Docker-based)
# bin/kamal setup    # provision servers
# bin/kamal deploy   # deploy
# bin/kamal rollback # rollback

# Dockerfile (Rails 8 generates this automatically)
# FROM ruby:3.3-slim
# ...standard Rails Dockerfile...

# Render.com
# render.yaml:
# services:
#   - type: web
#     name: myapp
#     runtime: ruby
#     buildCommand: bundle install && rails assets:precompile && rails db:migrate
#     startCommand: bundle exec puma -C config/puma.rb

Performance & Production

  • N+1 queries: use includes(), preload(), or eager_load(). Add Bullet gem in development to detect N+1s automatically.

  • Database indexes: index all foreign keys, columns used in WHERE/ORDER/GROUP BY. Use db-query-matchers gem in specs.

  • Fragment caching: cache @post, expires_in: 1.hour do — store rendered HTML in Redis. Russian doll caching for nested fragments.

  • HTTP caching: fresh_when(@post) — sets ETag and Last-Modified. Browsers + CDNs skip re-fetching unchanged resources.

  • Connection pooling: configure database.yml pool size to match Puma workers × threads. Match Sidekiq concurrency.

  • rack-mini-profiler: shows per-request SQL queries, timing, memory in-browser. Essential for finding slow pages.

  • asset pipeline: use Propshaft (Rails 8 default) or Sprockets. Fingerprint assets for long cache TTL (1 year).

  • Background jobs for slow work: never block web requests with email, image processing, third-party API calls.

Keep your Rails knowledge sharp.

Save this stack to your personal DevRecall — add your own notes, track what you're learning, and share what you know with the community.

Get started — free forever