All topics
Backend · Learning hub

Ruby on Rails notes for developers

Master Ruby on 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
Ruby on Rails

Rails MVC & Routing

Rails MVC & Routing Rails implements a strict MVC pattern. The router maps URLs to controller actions. Controllers coordinate between models and views. Masterin

Rails MVC & Routing

Rails implements a strict MVC pattern. The router maps URLs to controller actions. Controllers coordinate between models and views. Mastering routes.rb and controller conventions covers the vast majority of web request handling in Rails.

routes.rb

# config/routes.rb

Rails.application.routes.draw do
  # resources generates 7 RESTful routes (index/show/new/create/edit/update/destroy)
  resources :articles

  # Nested resources
  resources :posts do
    resources :comments, only: [:index, :create, :destroy]
  end

  # Singular resource (no :id in URL — e.g. user profile)
  resource :profile, only: [:show, :edit, :update]

  # Namespace — groups routes and controllers under a prefix
  namespace :admin do
    resources :users
    resources :analytics, only: [:index]
  end
  # => /admin/users, controllers in app/controllers/admin/users_controller.rb

  # Scope — URL prefix without affecting controller namespace
  scope :api do
    resources :products
  end
  # => /api/products, controllers still in app/controllers/products_controller.rb

  # API versioning with module and scope
  scope :api do
    namespace :v1 do
      resources :users
    end
  end
  # => /api/v1/users, controller: Api::V1::UsersController

  # Member routes (operate on a specific resource)
  resources :articles do
    member do
      post :publish
      delete :archive
    end
    collection do
      get :featured     # /articles/featured
    end
  end

  # Concerns — shared route fragments
  concern :commentable do
    resources :comments
  end
  resources :posts, concerns: :commentable
  resources :photos, concerns: :commentable

  # Named routes
  get "/about", to: "pages#about", as: :about
  # => about_path, about_url helpers generated

  root "dashboard#index"
end
# Inspect routes
rails routes                           # All routes
rails routes -g article                # Filter by pattern
rails routes --expanded                # Show full details

# Route helpers generated by resources :articles:
# articles_path          → /articles         (index, create)
# new_article_path       → /articles/new     (new)
# article_path(article)  → /articles/:id     (show, update, destroy)
# edit_article_path(art) → /articles/:id/edit (edit)

Controllers

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  before_action :authenticate_user!              # Runs before every action
  before_action :set_article, only: [:show, :edit, :update, :destroy]
  before_action :authorize_author!, only: [:edit, :update, :destroy]

  def index
    @articles = Article.published.order(created_at: :desc).page(params[:page])
    # render :index is implicit — renders app/views/articles/index.html.erb
  end

  def show
    # @article already set by before_action
    respond_to do |format|
      format.html                          # renders show.html.erb
      format.json { render json: @article }
    end
  end

  def new
    @article = Article.new
  end

  def create
    @article = current_user.articles.build(article_params)
    if @article.save
      redirect_to @article, notice: "Article created successfully."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @article.update(article_params)
      redirect_to @article, notice: "Article updated."
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @article.destroy!
    redirect_to articles_path, status: :see_other, notice: "Article deleted."
  end

  private

  def set_article
    @article = Article.find(params[:id])
  end

  # Strong parameters — whitelist what can be mass assigned
  def article_params
    params.require(:article).permit(:title, :body, :published, tag_ids: [])
  end

  def authorize_author!
    redirect_to root_path, alert: "Not authorized." unless @article.author == current_user
  end
end
Ruby on Rails

ActiveRecord & Migrations

ActiveRecord & Migrations ActiveRecord is Rails's ORM — it maps classes to tables and objects to rows. Mastering associations, scopes, validations, and the quer

ActiveRecord & Migrations

ActiveRecord is Rails's ORM — it maps classes to tables and objects to rows. Mastering associations, scopes, validations, and the query interface covers the full lifecycle of data in a Rails application.

Associations & Scopes

# app/models/user.rb
class User < ApplicationRecord
  # Associations
  has_many :posts, dependent: :destroy
  has_many :comments, dependent: :destroy
  has_one :profile, dependent: :destroy
  has_many :likes
  has_many :liked_posts, through: :likes, source: :post  # many-to-many via join table

  # Validations
  validates :email, presence: true, uniqueness: { case_sensitive: false },
                    format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :username, presence: true, length: { minimum: 3, maximum: 30 },
                       format: { with: /A[a-z0-9_]+z/, message: "only lowercase letters, numbers, underscores" }
  validates :age, numericality: { greater_than_or_equal_to: 18 }, allow_nil: true

  # Callbacks
  before_save :normalize_email
  after_create :send_welcome_email

  # Scopes — reusable query fragments
  scope :active,     -> { where(active: true) }
  scope :admins,     -> { where(role: :admin) }
  scope :recent,     -> { order(created_at: :desc) }
  scope :by_country, ->(country) { where(country: country) }

  private

  def normalize_email
    self.email = email.downcase.strip
  end

  def send_welcome_email
    WelcomeMailer.with(user: self).welcome.deliver_later
  end
end

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
  has_many :comments, dependent: :destroy
  has_many :taggings
  has_many :tags, through: :taggings

  validates :title, presence: true, length: { maximum: 255 }
  validates :body,  presence: true

  scope :published, -> { where(published: true) }
  scope :draft,     -> { where(published: false) }
end

Queries & Migrations

# ActiveRecord query interface
User.all                                          # All users (returns ActiveRecord::Relation)
User.find(1)                                      # By primary key (raises if not found)
User.find_by(email: "alice@example.com")          # First match or nil
User.where(active: true).order(:name).limit(20)
User.where("created_at > ?", 1.week.ago)
User.where(role: [:admin, :moderator])            # IN (:admin, :moderator)
User.where.not(role: :banned)

# Eager loading (avoid N+1 queries)
# N+1: posts.each { |p| p.user.name }   — fires 1 + N queries
Post.includes(:user, :tags).all           # 3 queries total
Post.eager_load(:user).where(users: { active: true })  # JOIN — allows WHERE on association
Post.preload(:comments)                   # Always uses separate queries (no JOINs)

# Select, group, count, pluck
User.count
User.where(active: true).count
User.group(:country).count                # { "US" => 120, "UK" => 45 }
User.pluck(:id, :email)                   # Returns raw array, not AR objects (fast)
User.select(:id, :email)                  # Returns AR objects with only those attrs

# update_all / delete_all (no callbacks or validations!)
Post.where(published: false).update_all(archived: true)
Post.where("created_at < ?", 6.months.ago).delete_all

# Transactions
ActiveRecord::Base.transaction do
  account.update!(balance: account.balance - 100)
  recipient.update!(balance: recipient.balance + 100)
end
# If either update fails, both are rolled back
# Generate a migration
rails generate migration AddPublishedToArticles published:boolean
rails generate migration CreateProducts name:string price:decimal stock:integer
rails generate migration AddIndexToUsersEmail

# Migration file — db/migrate/20240115123456_add_published_to_articles.rb
# class AddPublishedToArticles < ActiveRecord::Migration[7.1]
#   def change
#     add_column :articles, :published, :boolean, default: false, null: false
#     add_index  :articles, :published
#     add_index  :articles, [:user_id, :published]   # composite index
#     add_column :articles, :published_at, :datetime
#     remove_column :articles, :legacy_flag, :boolean  # pass old type for reversibility
#   end
# end

# Database commands
rails db:migrate                # Run pending migrations
rails db:rollback               # Roll back last migration
rails db:rollback STEP=3        # Roll back last 3 migrations
rails db:migrate:status         # Show migration status
rails db:seed                   # Run db/seeds.rb
rails db:reset                  # Drop, create, migrate, seed
rails db:schema:load            # Load schema.rb directly (faster than running all migrations)

# Rails console
rails console                   # Interactive Ruby with Rails loaded
rails console --sandbox         # Rolls back all changes on exit
Ruby on Rails

Views, Mailers & Jobs

Views, Mailers & Background Jobs Rails views use ERB templates with layouts and partials. ActionMailer handles email delivery. ActiveJob provides a unified back

Views, Mailers & Background Jobs

Rails views use ERB templates with layouts and partials. ActionMailer handles email delivery. ActiveJob provides a unified background job interface with adapters for Sidekiq, Delayed::Job, and others.

ERB Templates & Helpers

<!-- app/views/articles/index.html.erb -->
<% content_for :title, "Articles" %>

<h1>Articles</h1>

<% if @articles.any? %>
  <div class="articles">
    <% @articles.each do |article| %>
      <%# Render a partial (app/views/articles/_article.html.erb) %>
      <%= render article %>
      <%# Equivalent: render partial: "article", locals: { article: article } %>
    <% end %>
  </div>

  <!-- Pagination links (kaminari gem) -->
  <%= paginate @articles %>
<% else %>
  <p>No articles yet. <%= link_to "Create one", new_article_path %>.</p>
<% end %>

<!-- app/views/articles/_article.html.erb -->
<article class="article-card" id="article-<%= article.id %>">
  <h2><%= link_to article.title, article_path(article) %></h2>
  <p class="meta">
    By <%= article.user.name %> &middot;
    <%= time_tag article.created_at, article.created_at.strftime("%B %d, %Y") %>
  </p>
  <p><%= truncate(article.body, length: 200) %></p>
</article>

<!-- form_with generates a form that works with Rails UJS/Turbo -->
<!-- app/views/articles/_form.html.erb -->
<%= form_with model: @article do |f| %>
  <% if @article.errors.any? %>
    <div class="errors">
      <ul>
        <% @article.errors.full_messages.each do |msg| %>
          <li><%= msg %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <%= f.label :title %>
  <%= f.text_field :title, class: "form-control" %>

  <%= f.label :body %>
  <%= f.text_area :body, rows: 10, class: "form-control" %>

  <%= f.check_box :published %> <%= f.label :published %>

  <%= f.submit class: "btn btn-primary" %>
<% end %>

ActionMailer

# Generate a mailer
# rails generate mailer User welcome password_reset

# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  default from: "DevRecall <noreply@devrecall.com>"

  def welcome
    @user = params[:user]
    @login_url = login_url

    mail(
      to: @user.email,
      subject: "Welcome to DevRecall!"
    )
    # Renders app/views/user_mailer/welcome.html.erb + welcome.text.erb
  end

  def password_reset
    @user = params[:user]
    @token = params[:token]
    @expires_at = 2.hours.from_now

    mail(
      to: @user.email,
      subject: "Reset your password"
    )
  end
end

# app/views/user_mailer/welcome.html.erb
# <h1>Welcome, <%= @user.first_name %>!</h1>
# <p>Click here to get started: <%= link_to "Go to Dashboard", @login_url %></p>

# Deliver methods
UserMailer.with(user: @user).welcome.deliver_now     # Synchronous (blocks request)
UserMailer.with(user: @user).welcome.deliver_later   # Async via ActiveJob (preferred)

# config/environments/development.rb — use Letter Opener to preview emails in browser
config.action_mailer.delivery_method = :letter_opener
config.action_mailer.perform_deliveries = true

# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: "smtp.sendgrid.net",
  port: 587,
  user_name: "apikey",
  password: Rails.application.credentials.sendgrid_api_key,
  authentication: :plain,
  enable_starttls_auto: true
}

ActiveJob & Rails Credentials

# Generate a job
# rails generate job ProcessPayment

# app/jobs/process_payment_job.rb
class ProcessPaymentJob < ApplicationJob
  queue_as :default                # Queue name
  retry_on Stripe::RateLimitError, wait: 5.seconds, attempts: 3
  discard_on ActiveRecord::RecordNotFound

  def perform(order_id)
    order = Order.find(order_id)      # raises RecordNotFound → discarded
    PaymentService.new(order).process!
    OrderMailer.with(order: order).confirmation.deliver_later
  end
end

# Enqueue a job
ProcessPaymentJob.perform_later(order.id)
ProcessPaymentJob.set(wait: 5.minutes).perform_later(order.id)
ProcessPaymentJob.set(wait_until: Date.tomorrow.noon).perform_later(order.id)
ProcessPaymentJob.perform_now(order.id)   # Synchronous (for testing)

# Queue adapters (config/application.rb or environments)
config.active_job.queue_adapter = :sidekiq  # Production
config.active_job.queue_adapter = :async    # Development (in-process, no persistence)
config.active_job.queue_adapter = :test     # Test (enqueues but does not run)

# Sidekiq setup (Gemfile: gem "sidekiq")
# config/initializers/sidekiq.rb
Sidekiq.configure_server { |c| c.redis = { url: ENV["REDIS_URL"] } }
Sidekiq.configure_client { |c| c.redis = { url: ENV["REDIS_URL"] } }
# config/application.rb: config.active_job.queue_adapter = :sidekiq
# Start: bundle exec sidekiq

# Rails Credentials — encrypted secrets, committed to git
# EDITOR="code --wait" rails credentials:edit   # Open in VS Code
# Structure (config/credentials.yml.enc):
# secret_key_base: abc123...
# sendgrid_api_key: SG.xxx
# stripe:
#   secret_key: sk_live_xxx
#   webhook_secret: whsec_xxx

# Access in code
Rails.application.credentials.sendgrid_api_key
Rails.application.credentials.stripe[:secret_key]
# Environment-specific credentials
# rails credentials:edit --environment production
Rails.application.credentials.stripe[:secret_key]  # reads from config/credentials/production.yml.enc
Ruby on Rails

Rails Interview Questions

Rails Interview Questions Common Rails interview questions covering architecture, performance, security, and design patterns. Understanding these deeply — not j

Rails Interview Questions

Common Rails interview questions covering architecture, performance, security, and design patterns. Understanding these deeply — not just the terms — is what interviewers at senior level are looking for.

1. What is "Convention over Configuration" in Rails?

Rails assumes sensible defaults so you only write configuration when deviating from them. A model named Article automatically maps to the articles table, its controller is ArticlesController in app/controllers/articles_controller.rb, views live in app/views/articles/, and the route helper is articles_path. You never declare any of this — the convention handles it. The benefit is less boilerplate and a shared mental model across teams. The tradeoff is that Rails-specific magic can confuse developers unfamiliar with the conventions.

2. What is the N+1 query problem and how do you fix it?

N+1 occurs when you load a collection (1 query) then access an association on each record (N queries). Example: Post.all.each { |p| p.user.name } fires 1 query for posts then 1 per post for users. Fix with eager loading: Post.includes(:user).each { |p| p.user.name } loads all users in a second query. Use includes for most cases (uses 2 queries or LEFT JOIN depending on conditions). Use eager_load when you need to filter/sort by the association (always uses JOIN). Use preload to force separate queries. The Bullet gem detects N+1 in development automatically.

3. What is CSRF and how does Rails protect against it?

Cross-Site Request Forgery tricks authenticated users into unknowingly submitting requests to your app from a malicious site. Rails protects against this with an authenticity token: a unique random token embedded in every form and verified on every non-GET request. ApplicationController includes protect_from_forgery with: :exception by default. The token is stored in the session and compared with the token submitted in the form or the X-CSRF-Token header. API-only apps typically disable this and use stateless token auth (JWT, API keys) instead.

4. What is mass assignment vulnerability and how does Rails prevent it?

Mass assignment lets an attacker set arbitrary model attributes by crafting a request payload — e.g. setting admin: true on user signup. Rails 4+ requires strong parameters: you must explicitly whitelist permitted attributes in the controller using params.require(:user).permit(:name, :email, :password). Anything not whitelisted is filtered out and not passed to the model. This means the model itself is fully protected — you cannot accidentally expose a dangerous attribute just by adding it to the schema.

5. How does ActiveRecord protect against SQL injection?

ActiveRecord uses parameterized queries (prepared statements) for any query using the ? placeholder or hash conditions: User.where("email = ?", params[:email]) or User.where(email: params[:email]). Both are safe. The danger is string interpolation: User.where("email = '#{params[:email]}'") — this directly injects user input into SQL, enabling injection attacks. Always use placeholders or hash conditions. Named placeholders are cleaner for multiple values: User.where("created_at > :since AND role = :role", since: 1.week.ago, role: "admin").

6. What is the difference between concerns, service objects, and helpers?

Concerns (app/models/concerns/, app/controllers/concerns/) are modules that encapsulate shared behavior mixed into multiple models or controllers — they reduce duplication but can hide complexity. Service objects (plain Ruby classes in app/services/) encapsulate complex business logic that does not belong in a fat model or controller — e.g. ProcessOrderService, SendWelcomeEmailService. They are easy to test in isolation. Helpers (app/helpers/) are modules mixed into views for presentational logic like formatting dates or building complex HTML. The general Rails principle is "fat models, skinny controllers" but service objects prevent models from becoming too fat.

7. Asset pipeline vs Importmap vs Webpacker?

Asset Pipeline (Sprockets) — the original Rails approach: concatenates and fingerprints CSS/JS. Simple but limited for modern JS modules. Webpacker — introduced in Rails 5.1, wraps webpack for complex JS apps with npm packages, Babel, React. Deprecated and replaced. Importmap (Rails 7 default) — serves ES modules directly via import maps in the browser, no build step required. npm packages loaded via CDN or vendored. Ideal for most apps that do not need heavy JS processing. jsbundling-rails with esbuild/rollup/bun — when you need npm packages and a fast build step without webpack's complexity. The Rails 7+ recommendation: use importmap-rails for simple apps, jsbundling-rails + esbuild for complex frontend.

8. What is the difference between includes, eager_load, and preload?

All three eager load associations to avoid N+1 but differ in SQL strategy. preload always issues a separate query per association (safe, no joins). eager_load always uses a LEFT OUTER JOIN (single query, enables WHERE/ORDER on association columns). includes is smart: it uses preload by default but switches to eager_load automatically if you reference the association in a where/order clause. Best practices: use includes as your default. Use eager_load explicitly when you need to filter by an association attribute. Use preload when you want to guarantee no JOIN (e.g. to avoid Cartesian product issues with multiple has_many includes).

9. What is Turbo and how does it change Rails development?

Turbo (part of Hotwire, Rails 7 default) enables SPA-like navigation without writing JavaScript. Turbo Drive intercepts link clicks and form submissions, updates only the changed parts of the page over WebSockets or HTTP, maintaining scroll position and browser history. Turbo Frames scope partial page updates to specific regions. Turbo Streams push real-time updates from the server (append/prepend/replace/remove DOM elements) via WebSockets (Action Cable) or SSE. Combined with Stimulus (small JS framework for behavior), Hotwire covers most interactive UI needs without React/Vue, keeping logic on the server in Ruby.

Keep your Ruby on 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