Rails essentials: MVC, ActiveRecord and routing

The Rails request cycle, models and associations, migrations, resourceful routing, controllers, and the ActiveRecord query interface.

The request cycle

# config/routes.rb
Rails.application.routes.draw do
  root "books#index"

  resources :books do
    resources :reviews, only: %i[index create]
    member { post :publish }
    collection { get :search }
  end

  namespace :admin do
    resources :users
  end

  # /health without a controller
  get "health", to: proc { [200, {}, ["ok"]] }
end

# rails routes --expanded  shows every generated helper

A request flows through Rack middleware, the router, a controller action, the model layer, and a view. Understanding that order explains most Rails behaviour: middleware sees the request first, filters run before the action, and the response is rendered after the action returns.

Resource routePathAction
GET /books/booksindex
GET /books/new/books/newnew
POST /books/bookscreate
GET /books/:id/edit/books/1/editedit
PATCH /books/:id/books/1update
DELETE /books/:id/books/1destroy

Models and migrations

class CreateBooks < ActiveRecord::Migration[7.2]
  def change
    create_table :books do |t|
      t.string  :title, null: false
      t.references :author, null: false, foreign_key: true
      t.timestamps
    end
    add_index :books, :title
    add_index :books, %i[author_id published_at]
  end
end

class Book < ApplicationRecord
  belongs_to :author
  has_many :reviews, dependent: :destroy
  has_many :tags, through: :taggings

  enum :status, { draft: 0, published: 1, archived: 2 }, default: :draft

  validates :title, presence: true, length: { maximum: 200 }
  validates :isbn, uniqueness: true, allow_nil: true

  scope :published, -> { where(status: :published).where(arel_table[:published_at].lteq(Time.current)) }
  scope :recent, -> { order(published_at: :desc) }

  def self.search(term)
    where("title ILIKE ?", "%#{sanitize_sql_like(term)}%")
  end
end
  • dependent: :destroy runs callbacks for each child; dependent: :delete_all is a single fast delete with no callbacks. Choose deliberately for large tables.
  • A database constraint is the only real guarantee: validates :isbn, uniqueness: true races under concurrency, so add a unique index too.
  • The references macro creates the column, the index and optionally the foreign key - do not add them by hand.
  • enum gives you book.published? and Book.published, plus scopes, from one integer column.
⚠️
A migration that changes a column type or adds a NOT NULL constraint on a large table can lock it for the duration of a rewrite. Add the column nullable, backfill in batches, then add the constraint in a separate migration.

Query interface and controllers

# avoid N+1 with includes; join when you need to filter on the association
books = Book.includes(:author, :tags).published.recent.limit(20)

Book.joins(:author).where(authors: { active: true })
Book.where(published_at: 1.month.ago..).count
Book.group(:status).count
Book.where(author: Author.find_by(name: "Le Guin"))

# batches: constant memory for a big export
Book.published.find_each(batch_size: 500) { |book| Indexer.call(book) }

# upsert without a race
Book.upsert({ isbn: "9780", title: "New", author_id: 1 }, unique_by: :isbn)

class BooksController < ApplicationController
  before_action :set_book, only: %i[show edit update destroy]

  def index
    @books = Book.includes(:author).published.recent.page(params[:page])
  end

  def create
    @book = current_user.books.new(book_params)
    if @book.save
      redirect_to @book, notice: "Book created"
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def set_book = @book = Book.find(params[:id])

  def book_params = params.require(:book).permit(:title, :isbn, :author_id)
end
  • includes chooses between two queries and a join automatically; eager_load forces a LEFT JOIN when you filter on the association.
  • Strong parameters are mandatory: permit is the allowlist, and params.require raises on a missing root key.
  • find_each orders by primary key in batches; a query with limit inside it silently truncates the batch.
  • where(published_at: 1.month.ago..) is a beginless or endless range, translated to a >= comparison without string SQL.

FAQ

Should I use callbacks on models?
For simple invariants such as normalising a field, yes. For anything with side effects - sending mail, calling an API - no: use a service object or a job. Callbacks make bulk operations unpredictable and tests slow.
How do I know whether Rails is the problem or my query is?
Look at the log line for the action: the controller/view split is printed. If most of the time is in the view, you have N+1 or a heavy partial; if it is in the SQL, run EXPLAIN on the generated statement.

Testing with Minitest and RSpec Background jobs and the Ruby web stack

Last refreshed 2026-09-18.