Ruby on Rails #
Ruby on Rails is a web framework that changed how developers build web applications — with its Convention over Configuration and Don’t Repeat Yourself philosophy, Rails lets you build a functional app in minutes, not days. Behind this ease is a highly cohesive system: ActiveRecord for databases, ActionController for HTTP, ActionView for templates, ActionMailer for email, ActiveJob for background jobs, and ActionCable for WebSockets — all integrated and complementary. This article covers Rails comprehensively: from architecture and conventions to the design patterns used in modern production applications.
Rails MVC Architecture #
flowchart LR
Browser -->|HTTP Request| Router
Router -->|dispatch| Controller
Controller -->|query| Model
Model -->|data| Controller
Controller -->|render| View
View -->|HTML/JSON| Browser
Model <-->|SQL| Database[(Database)]app/
├── controllers/ ← ActionController — request/response logic
│ ├── application_controller.rb
│ └── products_controller.rb
├── models/ ← ActiveRecord — business logic and data
│ ├── application_record.rb
│ └── product.rb
├── views/ ← ActionView — HTML/JSON templates
│ └── products/
│ ├── index.html.erb
│ ├── show.html.erb
│ └── _card.html.erb
├── jobs/ ← ActiveJob — background jobs
├── mailers/ ← ActionMailer — email
├── channels/ ← ActionCable — WebSocket
└── services/ ← Service Objects (community convention)
Creating a New Rails Application #
# Create a new Rails app
rails new store_app --database=postgresql --css=tailwind --skip-test
rails new store_api --api --database=postgresql # API-only mode
# Basic structure
cd store_app
rails server # run on localhost:3000
# Generate components
rails generate controller Products index show
rails generate model Product name:string price:decimal stock:integer active:boolean
rails generate scaffold Order total:decimal status:string user:references
rails generate migration AddDescriptionToProducts description:text
rails generate job SendEmail
rails generate mailer Notification
# Database
rails db:create # create the database
rails db:migrate # run migrations
rails db:seed # populate initial data
rails db:reset # drop + create + migrate + seed
Routing #
Rails routing maps URLs to controller actions:
# config/routes.rb
Rails.application.routes.draw do
# RESTful resources — generates 7 standard routes
resources :products
# GET /products → products#index
# GET /products/new → products#new
# POST /products → products#create
# GET /products/:id → products#show
# GET /products/:id/edit → products#edit
# PATCH /products/:id → products#update
# DELETE /products/:id → products#destroy
# Limit the generated actions
resources :categories, only: [:index, :show]
resources :comments, except: [:destroy]
# Nested resources — orders belong to users
resources :users do
resources :orders, shallow: true # shallow: avoid overly deep URLs
end
# Member and collection routes
resources :products do
member do
patch :activate # PATCH /products/:id/activate
patch :deactivate
end
collection do
get :bestsellers # GET /products/bestsellers
get :promotions
end
end
# Namespaces — for API versioning
namespace :api do
namespace :v1 do
resources :products, only: [:index, :show, :create, :update]
end
end
# GET /api/v1/products → api/v1/products#index
# Single routes
get "about", to: "pages#about", as: :about
get "contact", to: "pages#contact"
root "pages#home" # root route
# Constraints
get "admin/*path", to: "admin#index", constraints: { subdomain: "admin" }
# Health check
get "up" => "rails/health#show", as: :rails_health_check
end
# View all routes
rails routes
rails routes --grep products # filter by pattern
ActiveRecord — Models and Database #
Schema Migrations #
# db/migrate/20240815000001_create_products.rb
class CreateProducts < ActiveRecord::Migration[7.1]
def change
create_table :products do |t|
t.string :name, null: false, limit: 200
t.text :description
t.decimal :price, null: false, precision: 15, scale: 2
t.integer :stock, null: false, default: 0
t.boolean :active, null: false, default: true
t.string :sku, limit: 50
t.string :image_url
t.references :category, null: false, foreign_key: true
t.timestamps # created_at and updated_at
end
add_index :products, :sku, unique: true
add_index :products, :active
add_index :products, [:category_id, :active]
add_index :products, :name
end
end
ActiveRecord Models #
# app/models/product.rb
class Product < ApplicationRecord
# Associations
belongs_to :category
has_many :order_items, dependent: :destroy
has_many :orders, through: :order_items
has_many :reviews, dependent: :destroy
has_one_attached :image # Active Storage
has_many_attached :additional_photos
# Validations
validates :name, presence: true, length: { minimum: 2, maximum: 200 }
validates :price, presence: true, numericality: { greater_than: 0 }
validates :stock, numericality: { greater_than_or_equal_to: 0 }
validates :sku, uniqueness: true, allow_blank: true,
format: { with: /\ASKU-\w+\z/, message: "must start with SKU-" }
# Scopes
scope :active, -> { where(active: true) }
scope :available, -> { active.where("stock > 0") }
scope :newest, -> { order(created_at: :desc) }
scope :bestsellers, -> { joins(:order_items).group(:id).order("COUNT(order_items.id) DESC") }
scope :in_price_range, ->(min, max) { where(price: min..max) }
scope :search, ->(q) { where("name ILIKE ?", "%#{q}%") }
# Callbacks
before_validation :normalize_name
before_save :calculate_discounted_price
after_create :log_new_product
after_update :invalidate_cache, if: :saved_change_to_price?
after_destroy :cleanup_files
# Enums
enum status: { draft: 0, published: 1, archived: 2 }
# Delegation
delegate :name, to: :category, prefix: true, allow_nil: true
# product.category_name → category.name
# Instance methods
def available?
active? && stock > 0
end
def formatted_price
"Rp #{price.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1.').reverse}"
end
def reduce_stock!(quantity)
raise "Insufficient stock" if stock < quantity
update!(stock: stock - quantity)
end
# Class methods
def self.total_stock_value
active.sum("price * stock")
end
private
def normalize_name
self.name = name.strip.squeeze(" ") if name
end
def calculate_discounted_price
self.discounted_price = price * 0.9 if category&.on_promo?
end
def log_new_product
Rails.logger.info "New product: #{name} (ID: #{id})"
end
def invalidate_cache
Rails.cache.delete("product:#{id}")
end
def cleanup_files
image.purge_later if image.attached?
end
end
ActiveRecord Queries #
# Finding records
Product.find(1) # raises if not found
Product.find_by(sku: "SKU-001") # nil if not found
Product.find_by!(sku: "SKU-001") # raises if not found
Product.where(active: true).first
# Conditions
Product.where(active: true)
Product.where("price > ?", 1_000_000)
Product.where("name ILIKE ?", "%laptop%")
Product.where(category_id: [1, 2, 3])
Product.where.not(active: false)
# Chaining scopes
Product.active.newest.limit(10).offset(20)
Product.available.in_price_range(500_000, 5_000_000).search("laptop")
# JOINs
Product.joins(:category).where(categories: { name: "Electronics" })
Product.left_outer_joins(:reviews).where(reviews: { id: nil }) # products without reviews
# Eager loading — prevent N+1
Product.includes(:category, :reviews) # LEFT OUTER JOIN
Product.preload(:category) # separate queries
Product.eager_load(:category) # INNER JOIN
# Aggregations
Product.active.count
Product.active.sum(:price)
Product.active.average(:price)
Product.active.minimum(:price)
Product.active.maximum(:price)
Product.group(:category_id).count
Product.group(:status).having("count(*) > 5").count
# Updates
Product.where(active: false).update_all(stock: 0) # directly in the DB
product.update(price: 15_000_000)
product.update!(price: 15_000_000) # raises on failure
# Deletes
product.destroy # runs callbacks
Product.where(stock: 0).destroy_all # runs callbacks per record
Product.where(stock: 0, active: false).delete_all # directly in the DB, skips callbacks
ActionController #
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
before_action :authenticate_user!
before_action :set_product, only: [:show, :edit, :update, :destroy, :activate]
before_action :authorize_admin!, only: [:new, :create, :edit, :update, :destroy]
# GET /products
def index
@products = Product.active.includes(:category).newest
@products = @products.search(params[:q]) if params[:q].present?
@products = @products.in_price_range(params[:min_price], params[:max_price]) if params[:min_price].present?
@products = @products.page(params[:page]).per(20) # with pagy or kaminari
end
# GET /products/:id
def show
@reviews = @product.reviews.newest.limit(5)
end
# GET /products/new
def new
@product = Product.new
end
# POST /products
def create
@product = Product.new(product_params)
if @product.save
redirect_to @product, notice: "Product created successfully"
else
render :new, status: :unprocessable_entity
end
end
# PATCH /products/:id
def update
if @product.update(product_params)
redirect_to @product, notice: "Product updated successfully"
else
render :edit, status: :unprocessable_entity
end
end
# DELETE /products/:id
def destroy
@product.destroy!
redirect_to products_path, notice: "Product deleted successfully", status: :see_other
end
# PATCH /products/:id/activate
def activate
@product.update!(active: true)
redirect_to @product, notice: "Product activated successfully"
end
private
def set_product
@product = Product.find(params[:id])
rescue ActiveRecord::RecordNotFound
redirect_to products_path, alert: "Product not found"
end
# Strong Parameters — whitelist which parameters may enter
def product_params
params.require(:product).permit(
:name, :description, :price, :stock, :sku,
:category_id, :active, :image,
additional_photos: []
)
end
def authorize_admin!
redirect_to root_path, alert: "Access denied" unless current_user.admin?
end
end
ApplicationController — Shared Filters and Helpers #
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Pundit::Authorization # authorization
include Pagy::Backend # pagination
before_action :set_locale
before_action :configure_permitted_parameters, if: :devise_controller?
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from Pundit::NotAuthorizedError, with: :access_denied
helper_method :current_user, :user_logged_in?
private
def authenticate_user!
redirect_to login_path, alert: "Please log in first" unless user_logged_in?
end
def user_logged_in?
current_user.present?
end
def current_user
@current_user ||= User.find_by(id: session[:user_id])
end
def set_locale
I18n.locale = params[:locale] || I18n.default_locale
end
def not_found
render file: Rails.root.join("public/404.html"), status: :not_found, layout: false
end
def access_denied
render file: Rails.root.join("public/403.html"), status: :forbidden, layout: false
end
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [:name, :phone])
end
end
API Controllers — JSON Responses #
# app/controllers/api/v1/products_controller.rb
module Api
module V1
class ProductsController < Api::BaseController
def index
products = Product.active.includes(:category).newest
render json: {
status: "success",
data: products.map { |p| serialize_product(p) },
meta: { total: products.count }
}
end
def show
product = Product.find(params[:id])
render json: { status: "success", data: serialize_product(product) }
rescue ActiveRecord::RecordNotFound
render json: { status: "error", message: "Product not found" },
status: :not_found
end
def create
product = Product.new(product_params)
if product.save
render json: { status: "success", data: serialize_product(product) },
status: :created
else
render json: { status: "error", errors: product.errors },
status: :unprocessable_entity
end
end
private
def product_params
params.require(:product).permit(:name, :price, :stock, :category_id)
end
def serialize_product(p)
{
id: p.id,
name: p.name,
price: p.price,
stock: p.stock,
category: p.category_name,
created_at: p.created_at.iso8601
}
end
end
end
end
Service Objects — Complex Business Logic #
Service Objects separate complex business logic from models and controllers:
# app/services/create_order_service.rb
class CreateOrderService
Result = Struct.new(:success?, :order, :error, keyword_init: true)
def initialize(user:, cart_items:, shipping_address:, payment_method:)
@user = user
@cart_items = cart_items
@shipping_address = shipping_address
@payment_method = payment_method
end
def call
validate_stock!
validate_user!
order = nil
ActiveRecord::Base.transaction do
order = create_order
reduce_stock
create_payment_transaction(order)
end
send_confirmation(order)
Result.new(success?: true, order: order)
rescue OutOfStockError => e
Result.new(success?: false, error: e.message)
rescue PaymentFailedError => e
Result.new(success?: false, error: "Payment failed: #{e.message}")
rescue => e
Rails.logger.error "CreateOrderService error: #{e.message}"
Result.new(success?: false, error: "Something went wrong, try again")
end
private
def validate_stock!
@cart_items.each do |item|
product = Product.lock.find(item[:product_id])
raise OutOfStockError, "#{product.name} is out of stock" if product.stock < item[:quantity]
end
end
def validate_user!
raise "User is not verified" unless @user.email_verified?
end
def create_order
total = @cart_items.sum { |i| Product.find(i[:product_id]).price * i[:quantity] }
order = Order.create!(
user: @user,
total: total,
status: :pending,
shipping_address: @shipping_address
)
@cart_items.each do |item|
order.order_items.create!(
product_id: item[:product_id],
quantity: item[:quantity],
price: Product.find(item[:product_id]).price
)
end
order
end
def reduce_stock
@cart_items.each do |item|
Product.find(item[:product_id]).reduce_stock!(item[:quantity])
end
end
def create_payment_transaction(order)
PaymentService.new(@payment_method).charge(order.total)
end
def send_confirmation(order)
NotificationMailer.order_confirmed(@user, order).deliver_later
end
end
# Usage in a controller
def create
result = CreateOrderService.new(
user: current_user,
cart_items: session[:cart],
shipping_address: address_params,
payment_method: params[:payment_method]
).call
if result.success?
redirect_to result.order, notice: "Order created successfully!"
else
flash[:alert] = result.error
render :checkout
end
end
ActionMailer #
# app/mailers/notification_mailer.rb
class NotificationMailer < ApplicationMailer
default from: "[email protected]"
def order_confirmed(user, order)
@user = user
@order = order
mail(
to: user.email,
subject: "Order ##{order.id} Confirmed"
)
end
def welcome(user)
@user = user
@url = root_url
attachments["guide.pdf"] = File.read(Rails.root.join("public/guide.pdf"))
mail(to: user.email, subject: "Welcome to the Store!")
end
end
# Send email
NotificationMailer.order_confirmed(user, order).deliver_now # synchronous
NotificationMailer.order_confirmed(user, order).deliver_later # async via ActiveJob
ActiveJob — Background Jobs #
# app/jobs/send_email_job.rb
class SendEmailJob < ApplicationJob
queue_as :email
retry_on Net::TimeoutError, wait: :polynomially_longer, attempts: 5
discard_on ActiveRecord::RecordNotFound
def perform(user_id, template, *args)
user = User.find(user_id)
NotificationMailer.send(template, user, *args).deliver_now
end
end
# Enqueue
SendEmailJob.perform_later(user.id, :welcome)
SendEmailJob.set(wait: 1.hour).perform_later(user.id, :reminder)
SendEmailJob.set(queue: :priority).perform_later(user.id, :important)
Concerns — Reusable Modules #
# app/models/concerns/searchable.rb
module Searchable
extend ActiveSupport::Concern
included do
scope :search, ->(q) { where("name ILIKE ?", "%#{sanitize_sql_like(q)}%") }
end
class_methods do
def full_search(q)
left_outer_joins(:tags)
.where("name ILIKE :q OR description ILIKE :q OR tags.name ILIKE :q", q: "%#{q}%")
.distinct
end
end
def matches?(keyword)
name.downcase.include?(keyword.downcase)
end
end
# app/models/concerns/auditable.rb
module Auditable
extend ActiveSupport::Concern
included do
has_many :audit_logs, as: :auditable, dependent: :destroy
after_create { log_change(:create) }
after_update { log_change(:update, changed_attributes) }
after_destroy { log_change(:destroy) }
end
private
def log_change(action, changes = {})
audit_logs.create!(
action: action,
changes: changes,
user: Current.user,
ip_address: Current.ip_address
)
end
end
# Use in models
class Product < ApplicationRecord
include Searchable
include Auditable
end
Encrypted Credentials #
Rails provides an encrypted credentials system for securely storing secrets:
# Edit credentials
EDITOR=nano rails credentials:edit
# Edit per environment
EDITOR=nano rails credentials:edit --environment production
# config/credentials.yml.enc (contents after decryption)
secret_key_base: "very_long_..."
database:
password: "production_db_password"
stripe:
secret_key: "sk_live_..."
public_key: "pk_live_..."
midtrans:
server_key: "Mid-server-..."
client_key: "Mid-client-..."
# Access in the application
Rails.application.credentials.stripe[:secret_key]
Rails.application.credentials.dig(:midtrans, :server_key)
Rails.application.credentials.database[:password]
ActionCable — Real-Time #
# app/channels/notification_channel.rb
class NotificationChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
def unsubscribed; end
end
# Broadcast from anywhere
NotificationChannel.broadcast_to(
user,
{ type: "new_order", message: "Your order has been received!" }
)
A Well-Structured Application #
app/
├── controllers/
│ ├── concerns/ ← Controller concerns
│ ├── api/v1/ ← API controllers
│ └── admin/ ← Admin controllers
├── models/
│ ├── concerns/ ← Model concerns
│ └── *.rb
├── services/ ← Service Objects (business logic)
├── queries/ ← Query Objects (complex queries)
├── policies/ ← Pundit policies (authorization)
├── serializers/ ← JSON serializers
├── forms/ ← Form Objects (complex form validation)
├── jobs/ ← Background jobs
├── mailers/ ← Email
└── channels/ ← WebSocket
Summary #
- Convention over Configuration — follow Rails conventions (snake_case files, PascalCase classes) and you need no configuration; fighting the conventions adds complexity instead.
- Strong Parameters are mandatory — always use
params.require().permit()in controllers; without it, all user parameters could enter the database (mass assignment vulnerability).- N+1 queries are the main enemy — always use
includes,preload, oreager_loadwhen accessing associations in loops; use the Bullet gem to detect N+1 automatically.- Service Objects for complex business logic — if a controller or model method grows beyond 10 lines, consider moving it to a Service Object; easier to test and reuse.
- Concerns for code shared by many models —
include Searchable,include Auditableis cleaner than copy-pasting the same code into many models.deliver_laternotdeliver_nowfor email — synchronously sent email slows down responses; always send via ActiveJob withdeliver_later.- Encrypted credentials for all secrets — don’t store API keys, database passwords, or other secrets in committed
.envfiles; userails credentials:edit.update_allanddelete_allskip callbacks — use them only when callbacks truly aren’t needed; if important callbacks exist (cache invalidation, audit logs), useeach.updateoreach.destroy.- Scopes for frequently used queries —
Product.active.newest.limit(10)is far cleaner and more reusable thanProduct.where(active: true).order(created_at: :desc).limit(10).rescue_fromin ApplicationController — handle common exceptions (RecordNotFound, NotAuthorized) in one place so all controllers behave consistently.