What is Ruby on Rails?
Rails is a full-stack web framework for Ruby. It ships with everything a database-backed web application needs: an ORM, a router, a view layer, background jobs, mailers, caching, WebSockets and a test harness. Instead of choosing and wiring those pieces yourself, you inherit a coherent stack that was designed to work together.
Its defining idea is convention over configuration. The framework assumes a sensible structure — Post lives in app/models/post.rb, maps to a posts table, and is served by PostsController — and you only write configuration when you deviate from it. The Rails team calls this philosophy omakase, the chef’s choice: trust the defaults, and you will move faster.
That trade is why Rails has powered products from Basecamp and GitHub to Shopify and HEY for twenty years, and why a single developer can still ship a serious application in a weekend.
Convention over configuration
Every Rails project has the same skeleton, so you never wonder where something goes.
app/
├── controllers/ # request handling
├── models/ # domain and persistence
├── views/ # templates
├── jobs/ # background work
├── mailers/ # email
└── javascript/ # Stimulus controllers
config/
├── routes.rb # the URL map
└── database.yml # connection settings
db/
└── migrate/ # schema history
The payoff is predictability. A new developer opens the repository and already knows that the User model is in app/models/user.rb and that its table is users. Naming is not a style debate; it is an interface the framework relies on.
The MVC request cycle
Rails is a Model-View-Controller framework, and every HTTP request follows the same path:
- Routing matches the URL to a controller action.
- The controller loads or changes data through models.
- Models enforce the domain and talk to the database.
- A view renders the response, usually as HTML.
# config/routes.rb
Rails.application.routes.draw do
resources :posts
root "posts#index"
end
A request to GET /posts lands on PostsController#index, which prepares @posts, after which app/views/posts/index.html.erb is rendered. Keeping each layer focused is what keeps a Rails app maintainable as it grows.
The command line is the framework
You barely touch Rails through configuration files. Most of the work happens in the terminal.
rails new shop --database=postgresql
cd shop
bin/rails generate scaffold Post title:string body:text published:boolean
bin/rails generate model Comment post:references body:text
bin/rails generate controller Pages home about
bin/rails db:create db:migrate
bin/rails server
generate writes the model, migration, controller, routes, views and tests for you, and the generator output tells you what it changed. Read every generated file before you keep it — the fastest way to learn Rails is to study what it writes and then delete what you do not need.
Active Record: the model layer
Active Record is the ORM. A model class wraps a database table, and an instance wraps a row.
Migrations
Migrations are versioned, reversible schema changes stored in db/migrate. They are the only supported way to change the database.
class CreatePosts < ActiveRecord::Migration[8.0]
def change
create_table :posts do |t|
t.string :title, null: false
t.text :body, null: false
t.boolean :published, default: false, null: false
t.references :author, null: false, foreign_key: { to_table: :users }
t.timestamps
end
add_index :posts, %i[published created_at]
end
end
Run bin/rails db:migrate to apply it and bin/rails db:rollback to undo it. Because change is reversible, Rails knows how to reverse the operations without you writing a down method.
Associations
Associations describe relationships and generate convenient methods.
class User < ApplicationRecord
has_many :posts, dependent: :destroy
has_many :comments, through: :posts
end
class Post < ApplicationRecord
belongs_to :author, class_name: "User", inverse_of: :posts
has_many :comments, dependent: :destroy
has_one_attached :cover
end
With that in place, user.posts, post.author and post.comments.create(body: "...") all work, and dependent: :destroy keeps orphaned rows out of the database.
Validations and scopes
Validations protect the domain at the model layer, where every write passes through.
class Post < ApplicationRecord
validates :title, presence: true, length: { maximum: 120 }
validates :body, presence: true
validates :slug, uniqueness: true
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
before_validation :generate_slug, on: :create
private
def generate_slug
self.slug ||= title.to_s.parameterize
end
end
Scopes return relations, so they compose: Post.published.recent.limit(20) reads like a sentence and runs as one query. When a scope grows past a line or two, promote it to a class method so it stays readable.
Avoiding N+1 queries
The most common performance problem in Rails is the N+1 query: one query for a list, then one more for each row’s association.
# Two queries total, no matter how many posts.
posts = Post.includes(:author, :comments).recent
posts.each do |post|
puts "#{post.title} by #{post.author.name}"
end
Use includes when you will access the association, preload when you want a separate query, and eager_load when you also filter on the association. Add the bullet gem to development and it will flag every N+1 before it reaches production.
Controllers and RESTful routes
resources declares the conventional CRUD routes and maps them to controller actions with matching names.
# config/routes.rb
Rails.application.routes.draw do
resources :posts do
resources :comments, only: %i[index create destroy]
end
namespace :api do
namespace :v1 do
resources :posts, only: %i[index show create update destroy]
end
end
end
Controllers stay thin. They load data, call the domain, and choose a response. Business logic belongs in models or service objects, not in actions.
class PostsController < ApplicationController
before_action :set_post, only: %i[show update destroy]
def index
@posts = Post.published.includes(:author).recent
end
def create
@post = Post.new(post_params)
if @post.save
redirect_to @post, notice: "Post created."
else
render :new, status: :unprocessable_entity
end
end
private
def set_post
@post = Post.find(params[:id])
end
def post_params
params.require(:post).permit(:title, :body, :published, tag_ids: [])
end
end
Strong parameters are not optional. params.require(:post).permit(...) is the boundary that stops a client from setting columns you never intended, such as admin or role.
Views, ERB and layouts
Views are ERB templates: HTML with embedded Ruby. Rails escapes output by default, which removes a whole class of cross-site scripting bugs.
<%# app/views/posts/index.html.erb %>
<h1>Posts</h1>
<%= link_to "New post", new_post_path %>
<ul>
<% @posts.each do |post| %>
<li>
<%= link_to post.title, post %>
<span><%= pluralize(post.comments.size, "comment") %></span>
</li>
<% end %>
</ul>
Use <%= %> to output and <% %> to execute without output. A layout in app/views/layouts/application.html.erb wraps every page, and partials — files beginning with an underscore — let you reuse markup with render "post", post: post or simply render @posts.
Hotwire: Turbo and Stimulus
Modern Rails adds interactivity without a single-page app. Turbo intercepts links and forms and swaps page fragments over the wire; Stimulus adds small JavaScript behaviours where you genuinely need them.
<%# Turbo Frames scope an update to one part of the page %>
<%= turbo_frame_tag "comments" do %>
<%= render @post.comments %>
<% end %>
// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["menu"];
toggle() {
this.menuTarget.classList.toggle("hidden");
}
}
This is progressive enhancement in practice: server-rendered HTML is the baseline, and JavaScript upgrades it. Most Rails apps never need a client-side router.
Assets and import maps
Rails 7 replaced the old Sprockets pipeline with import maps by default and offers Propshaft as a simpler asset pipeline. You can pin a library without a Node build step.
bin/importmap pin local-time
bin/rails assets:precompile
If you prefer a bundler, bin/rails javascript:install:esbuild or :vite switches the setup. The principle is the same: serve what the browser understands and keep the toolchain out of the way until you need it.
Background jobs
Anything slow — sending email, calling an API, generating a report — belongs in a job, not in the request. Active Job is the common interface; the adapter decides where jobs run.
class PublishPostJob < ApplicationJob
queue_as :default
retry_on Net::ReadTimeout, wait: :polynomially_longer, attempts: 5
def perform(post_id)
Post.find(post_id).publish!
end
end
PublishPostJob.perform_later(post.id)
# config/application.rb
config.active_job.queue_adapter = :solid_queue # or :sidekiq
Rails 8 ships Solid Queue, which keeps jobs in the database and needs no Redis, so a single-server app has one fewer moving part. Sidekiq remains a strong choice at high volume.
Testing
Rails generates tests with everything it creates. Minitest ships in the box and is fast; RSpec is the popular alternative with a more expressive syntax.
# test/models/post_test.rb
require "test_helper"
class PostTest < ActiveSupport::TestCase
test "requires a title" do
post = Post.new(body: "Hello")
assert_not post.valid?
assert_includes post.errors[:title], "can't be blank"
end
end
# spec/models/post_spec.rb
RSpec.describe Post, type: :model do
it "is invalid without a title" do
expect(Post.new(body: "Hello")).not_to be_valid
end
end
Controller tests make real requests in-process, which catches routing and parameter bugs that unit tests miss.
# test/controllers/posts_controller_test.rb
class PostsControllerTest < ActionDispatch::IntegrationTest
test "lists published posts" do
Post.create!(title: "Hi", body: "There", published: true)
get posts_url
assert_response :success
end
end
Run everything with bin/rails test or bundle exec rspec. Add bin/rails test:system for browser-driven system tests when a flow matters enough to test end to end.
The Rails console
bin/rails console boots your application with every model and helper available. It is the fastest way to explore data, test a query and reproduce a bug.
bin/rails console
bin/rails console --sandbox # rolls back all changes on exit
Post.published.recent.limit(5)
Post.where(published: false).update_all(published: true)
Post.includes(:author).find_each { |post| puts post.title }
--sandbox is a safety net while you experiment: any write is rolled back when you quit.
Best practices
- Keep controllers thin; move domain logic into models or service objects.
- Eager load associations with
includesand watch the logs for N+1 queries. - Change the database only through migrations, and commit them with the code.
- Use strong parameters for every write and validate at the model layer.
- Add database indexes for the columns you filter and sort on.
- Push slow work into background jobs instead of the request cycle.
- Write a test for every bug you fix, then keep the suite fast.
Common mistakes
- Rendering a list without
includesand shipping an N+1 to production. - Putting business logic in controllers until actions are hundreds of lines long.
- Using
params[:post]directly instead of strong parameters. - Editing
db/schema.rbby hand instead of writing a migration. - Forgetting
dependent:options and leaving orphaned records behind. - Reaching for a service object or a gem before the model has earned it.
- Running heavy queries in views instead of loading data in the controller.
Where to go next
Rails teaches a durable way of thinking: resources, conventions and a single place for each concern. If you like the batteries-included model, Laravel applies the same philosophy in PHP, while Django does it in Python. Because Rails routing is built on resources, the REST APIs guide explains the ideas underneath. And since most Rails apps run on a relational database, deepen your PostgreSQL knowledge next.