Ruby on Rails Guide
Applies to: Rails 7+, Ruby 3.2+, Hotwire/Turbo, ActiveRecord, Action Cable
Core Principles
- •Convention over Configuration: Follow Rails conventions -- naming, directory structure, RESTful routes
- •Fat Models, Thin Controllers: Business logic in models and service objects, controllers handle request/response only
- •DRY: Use concerns, partials, helpers, and shared services to avoid repetition
- •RESTful by Default: Design resources around standard CRUD actions before adding custom routes
- •Security by Default: CSRF, XSS protection, strong parameters, and parameterized queries are built in
When to Use Rails
Good fit: Full-stack web apps, MVPs, CMS, e-commerce, SaaS, Hotwire/Turbo SPA-like UX, API backends.
Consider alternatives: Minimal microservices (Sinatra, Hanami), heavy real-time streaming, strict type safety needs (Rust, Go).
Project Structure
myapp/ ├── app/ │ ├── controllers/ │ │ ├── application_controller.rb │ │ ├── concerns/ # Controller concerns (auth, pagination) │ │ └── api/ │ │ └── v1/ # Versioned API controllers │ ├── models/ │ │ ├── application_record.rb │ │ └── concerns/ # Model concerns (searchable, sluggable) │ ├── views/ │ │ ├── layouts/ # Application layouts │ │ │ └── application.html.erb │ │ ├── shared/ # Shared partials (_navbar, _footer) │ │ └── posts/ # Resource-specific views │ ├── helpers/ # View helpers │ ├── jobs/ # ActiveJob classes │ ├── mailers/ # Action Mailer classes │ ├── channels/ # Action Cable channels │ └── services/ # Service objects (custom directory) ├── config/ │ ├── routes.rb # Route definitions │ ├── database.yml # Database configuration │ ├── environments/ # Per-environment settings │ └── initializers/ # Boot-time configuration ├── db/ │ ├── migrate/ # Migration files │ ├── schema.rb # Current schema snapshot │ └── seeds.rb # Seed data ├── lib/ │ └── tasks/ # Custom Rake tasks ├── test/ # Minitest (default) or spec/ for RSpec │ ├── models/ │ ├── controllers/ │ ├── integration/ │ └── system/ ├── Gemfile └── Gemfile.lock
- •Place business logic in
app/services/; use concerns for shared model/controller behavior - •Namespace API controllers under
Api::V1; keep shared partials inviews/shared/
Guardrails
Controllers
- •Keep controllers under 100 lines total
- •Limit each action to 10 lines (delegate to services for complex logic)
- •Always use
before_actionfor authentication and resource loading - •Always use strong parameters via private
*_paramsmethods - •Return
status: :unprocessable_entityfor failed form submissions - •Use
status: :see_other(303) forredirect_toafter DELETE - •Use
respond_toblocks when serving multiple formats
Models
- •Always add validations for required fields and constraints
- •Always add
dependent:option onhas_many/has_oneassociations - •Use
scopefor reusable queries (never build queries in controllers) - •Use
enumwith explicit integer or string mappings - •Use
before_validationfor data normalization (downcase, strip) - •Avoid heavy logic in callbacks -- prefer service objects for side effects
- •Always define
counter_cache: truefor belongs_to when parent displays counts
Migrations
- •Always add
null: falsefor required columns - •Always add database indexes for foreign keys and frequently queried columns
- •Always add unique indexes where uniqueness is required
- •Use
referenceswithforeign_key: truefor associations - •Set sensible defaults with
default:for boolean and status columns - •Include both
upanddownmethods for irreversible migrations - •Never modify a migration after it has been applied to production
Security
- •Strong parameters: never use
params.permit!(permit all) - •CSRF protection: enabled by default, skip only for API controllers with token auth
- •SQL injection: use ActiveRecord query methods, never string interpolation in queries
- •XSS: ERB auto-escapes by default; never use
raworhtml_safewith user data - •Mass assignment: only permit explicitly needed attributes
- •Secrets: use
Rails.application.credentialsor environment variables - •Set
force_sslin production - •Use
content_security_policyconfiguration in production
Performance
- •Always use
includes(orpreload/eager_load) to prevent N+1 queries - •Add database indexes for all foreign keys and commonly filtered columns
- •Use
counter_cachefor association counts displayed in views - •Use pagination for all list endpoints (Kaminari or Pagy)
- •Use fragment caching (
cache @record do) for expensive view rendering - •Use background jobs (ActiveJob + Sidekiq) for slow operations
- •Use
find_eachinstead ofeachwhen iterating over large datasets
MVC Conventions
Models
# app/models/post.rb
class Post < ApplicationRecord
# 1. Associations
belongs_to :user
belongs_to :category, optional: true
has_many :comments, dependent: :destroy
has_many :taggings, dependent: :destroy
has_many :tags, through: :taggings
has_one_attached :featured_image
# 2. Validations
validates :title, presence: true, length: { maximum: 255 }
validates :body, presence: true
# 3. Enums
enum :status, { draft: 0, published: 1, archived: 2 }
# 4. Scopes
scope :published, -> { where(status: :published) }
scope :recent, -> { order(created_at: :desc) }
scope :by_category, ->(cat) { where(category: cat) }
scope :search, ->(q) {
where("title ILIKE :q OR body ILIKE :q", q: "%#{sanitize_sql_like(q)}%")
}
# 5. Callbacks (keep minimal)
before_validation :normalize_title
# 6. Instance methods
def publish!
update!(status: :published, published_at: Time.current)
end
private
def normalize_title
self.title = title&.strip
end
end
Model ordering convention: Associations, validations, enums, scopes, callbacks, class methods, instance methods, private methods.
Controllers
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
before_action :authenticate_user!, except: [:index, :show]
before_action :set_post, only: [:show, :edit, :update, :destroy]
before_action :authorize_post!, only: [:edit, :update, :destroy]
def index
@posts = Post.published
.includes(:user, :category)
.recent
.page(params[:page])
.per(20)
end
def show; end
def new
@post = current_user.posts.build
end
def create
@post = current_user.posts.build(post_params)
if @post.save
redirect_to @post, notice: "Post created."
else
render :new, status: :unprocessable_entity
end
end
def edit; 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, notice: "Post deleted.", status: :see_other
end
private
def set_post
@post = Post.find(params[:id])
end
def authorize_post!
redirect_to posts_url, alert: "Not authorized." unless @post.user == current_user
end
def post_params
params.require(:post).permit(:title, :body, :category_id, :status, :featured_image, tag_ids: [])
end
end
Views and Partials
- •Use
_form.html.erbpartial shared betweennewandedit - •Use collection rendering:
render @posts(auto-maps to_post.html.erb) - •Use locals:
render partial: "post", locals: { post: @post } - •Use
content_for :titlefor page-specific titles - •Use helpers for complex view logic (not inline ERB conditionals)
Routing
# config/routes.rb
Rails.application.routes.draw do
root "home#index"
# Authentication
get "login", to: "sessions#new"
post "login", to: "sessions#create"
delete "logout", to: "sessions#destroy"
# RESTful resources
resources :posts do
resources :comments, only: [:create, :destroy]
member do
post :publish
post :unpublish
end
end
resources :categories, only: [:index, :show] do
resources :posts, only: [:index]
end
# Admin namespace
namespace :admin do
root "dashboard#index"
resources :users
resources :posts
end
# API namespace
namespace :api do
namespace :v1 do
resources :posts, only: [:index, :show, :create, :update, :destroy]
end
end
# Health check
get "health", to: "health#show"
end
Route conventions:
- •Use
resourcesfor standard CRUD (generates 7 RESTful routes) - •Use
only:orexcept:to limit generated routes - •Use
memberfor actions on a specific record,collectionfor actions on the set - •Nest resources only one level deep; use
shallow: truefor deeper nesting - •Namespace admin and API routes separately
Migrations
# db/migrate/20240115000000_create_posts.rb
class CreatePosts < ActiveRecord::Migration[7.1]
def change
create_table :posts do |t|
t.references :user, null: false, foreign_key: true
t.references :category, foreign_key: true
t.string :title, null: false
t.text :body, null: false
t.integer :status, default: 0, null: false
t.datetime :published_at
t.integer :comments_count, default: 0, null: false
t.timestamps
end
add_index :posts, [:user_id, :status]
add_index :posts, :published_at
end
end
- •Use
t.referencesfor foreign keys (adds index automatically) - •Use
t.timestampsforcreated_atandupdated_at - •Add composite indexes for common query patterns
- •Use
comments_countwithcounter_cache: trueon the association
Service Objects
# app/services/application_service.rb
class ApplicationService
def self.call(...)
new(...).call
end
end
# app/services/posts/publish_service.rb
module Posts
class PublishService < ApplicationService
def initialize(post:, user:)
@post = post
@user = user
end
def call
return ServiceResult.failure(["Not authorized"]) unless @user == @post.user
ActiveRecord::Base.transaction do
@post.update!(status: :published, published_at: Time.current)
notify_subscribers
end
ServiceResult.success(@post)
rescue ActiveRecord::RecordInvalid => e
ServiceResult.failure(e.record.errors.full_messages)
end
private
def notify_subscribers
PostMailer.published(@post).deliver_later
end
end
end
- •Use
self.call(...)class method pattern for clean invocation - •Wrap multi-step operations in
ActiveRecord::Base.transaction - •Return a result object (success/failure) instead of raising
- •Namespace services by domain:
Posts::PublishService,Users::CreateService
Rails Commands
# Application rails new myapp --database=postgresql --css=tailwind rails new myapp --api # API-only mode rails server # Start dev server rails console # Interactive console rails routes # List all routes # Generators rails generate model User name:string email:string rails generate controller Posts index show new create rails generate scaffold Article title:string body:text rails generate migration AddStatusToPosts status:integer # Database rails db:create # Create database rails db:migrate # Run pending migrations rails db:rollback # Undo last migration rails db:seed # Run seeds.rb rails db:reset # Drop, create, migrate, seed # Testing rails test # Run all tests rails test:models # Model tests only rails test:system # System tests only # Assets and dependencies bundle install # Install gems rails assets:precompile # Compile assets for production
Testing
Minitest (Default)
# test/models/post_test.rb
require "test_helper"
class PostTest < ActiveSupport::TestCase
def setup
@post = posts(:first_post)
end
test "valid post" do
assert @post.valid?
end
test "invalid without title" do
@post.title = nil
assert_not @post.valid?
assert_includes @post.errors[:title], "can't be blank"
end
test "publish! sets status and timestamp" do
@post.publish!
assert @post.published?
assert_not_nil @post.published_at
end
end
Testing Standards
- •Use fixtures (default) or
factory_botfor test data - •Test validations, associations, scopes, and instance methods on models
- •Test authentication, authorization, and response codes on controllers
- •Use system tests (Capybara) for critical user flows
- •Coverage target: >80% for models and services, >60% overall
- •Test names describe behavior:
test "user cannot edit others' posts" - •See references/patterns.md for controller and system test examples
Dependencies
Core: rails ~> 7.1, pg, puma, redis, turbo-rails, stimulus-rails, importmap-rails
Auth: bcrypt (has_secure_password)
Background: sidekiq
Pagination: kaminari or pagy
Dev/Test: debug, capybara, selenium-webdriver, web-console, rack-mini-profiler
Optional: rspec-rails, factory_bot_rails, faker, shoulda-matchers, webmock
Best Practices
Do
- •Follow RESTful conventions for routes and controllers
- •Use
includes/preloadon every association accessed in views - •Extract business logic to service objects
- •Use scopes for all reusable query patterns
- •Use
find_eachfor batch operations on large datasets - •Use background jobs for email, notifications, and heavy processing
- •Use fragment caching for expensive view partials
- •Keep secrets in credentials or environment variables
Don't
- •Put business logic in controllers or views
- •Use
params.permit!(mass-assignment vulnerability) - •Use string interpolation in SQL queries
- •Skip database indexes on foreign keys
- •Use
Model.allwithout pagination or limits - •Modify migrations after they have been applied to production
- •Use
raw/html_safewith user-provided data - •Rely heavily on callbacks for business logic (use services)
Advanced Topics
For detailed code examples and advanced patterns, see:
- •references/patterns.md -- ActiveRecord advanced patterns, Hotwire/Turbo, Action Cable, ActiveJob, API mode, deployment, and testing strategies