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 helperA 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 route | Path | Action |
|---|---|---|
GET /books | /books | index |
GET /books/new | /books/new | new |
POST /books | /books | create |
GET /books/:id/edit | /books/1/edit | edit |
PATCH /books/:id | /books/1 | update |
DELETE /books/:id | /books/1 | destroy |
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
enddependent: :destroyruns callbacks for each child;dependent: :delete_allis a single fast delete with no callbacks. Choose deliberately for large tables.- A database constraint is the only real guarantee:
validates :isbn, uniqueness: trueraces under concurrency, so add a unique index too. - The
referencesmacro creates the column, the index and optionally the foreign key - do not add them by hand. enumgives youbook.published?andBook.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)
endincludeschooses between two queries and a join automatically;eager_loadforces a LEFT JOIN when you filter on the association.- Strong parameters are mandatory:
permitis the allowlist, andparams.requireraises on a missing root key. find_eachorders by primary key in batches; a query withlimitinside 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.Related
Testing with Minitest and RSpec Background jobs and the Ruby web stack
Last refreshed 2026-09-18.