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, or eager_load when 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 modelsinclude Searchable, include Auditable is cleaner than copy-pasting the same code into many models.
  • deliver_later not deliver_now for email — synchronously sent email slows down responses; always send via ActiveJob with deliver_later.
  • Encrypted credentials for all secrets — don’t store API keys, database passwords, or other secrets in committed .env files; use rails credentials:edit.
  • update_all and delete_all skip callbacks — use them only when callbacks truly aren’t needed; if important callbacks exist (cache invalidation, audit logs), use each.update or each.destroy.
  • Scopes for frequently used queriesProduct.active.newest.limit(10) is far cleaner and more reusable than Product.where(active: true).order(created_at: :desc).limit(10).
  • rescue_from in ApplicationController — handle common exceptions (RecordNotFound, NotAuthorized) in one place so all controllers behave consistently.

← Previous: Memcached   Next: Sinatra →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact