Ruby on Rails

Ruby on Rails #

Ruby on Rails adalah web framework yang mengubah cara developer membangun aplikasi web — dengan filosofi Convention over Configuration dan Don’t Repeat Yourself, Rails memungkinkan kamu membangun aplikasi yang fungsional dalam hitungan menit, bukan hari. Di balik kemudahan ini ada sistem yang sangat kohesif: ActiveRecord untuk database, ActionController untuk HTTP, ActionView untuk template, ActionMailer untuk email, ActiveJob untuk background job, dan ActionCable untuk WebSocket — semua terintegrasi dan saling melengkapi. Artikel ini membahas Rails secara menyeluruh: dari arsitektur dan konvensi hingga pola desain yang digunakan di aplikasi produksi modern.

Arsitektur MVC Rails #

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 — logika request/response
│   ├── application_controller.rb
│   └── produk_controller.rb
├── models/             ← ActiveRecord — logika bisnis dan data
│   ├── application_record.rb
│   └── produk.rb
├── views/              ← ActionView — template HTML/JSON
│   └── produk/
│       ├── index.html.erb
│       ├── show.html.erb
│       └── _card.html.erb
├── jobs/               ← ActiveJob — background job
├── mailers/            ← ActionMailer — email
├── channels/           ← ActionCable — WebSocket
└── services/           ← Service Object (konvensi komunitas)

Membuat Aplikasi Rails Baru #

# Buat aplikasi Rails baru
rails new toko_app --database=postgresql --css=tailwind --skip-test
rails new toko_api  --api --database=postgresql   # API-only mode

# Struktur dasar
cd toko_app
rails server   # jalankan di localhost:3000

# Generate komponen
rails generate controller Produk index show
rails generate model Produk nama:string harga:decimal stok:integer aktif:boolean
rails generate scaffold Pesanan total:decimal status:string pengguna:references
rails generate migration AddDeskripsiToProduk deskripsi:text
rails generate job KirimEmail
rails generate mailer Notifikasi

# Database
rails db:create    # buat database
rails db:migrate   # jalankan migrasi
rails db:seed      # isi data awal
rails db:reset     # drop + create + migrate + seed

Routing #

Routing Rails memetakan URL ke controller action:

# config/routes.rb
Rails.application.routes.draw do
  # RESTful resources — generate 7 route standar
  resources :produk
  # GET    /produk          → produk#index
  # GET    /produk/new      → produk#new
  # POST   /produk          → produk#create
  # GET    /produk/:id      → produk#show
  # GET    /produk/:id/edit → produk#edit
  # PATCH  /produk/:id      → produk#update
  # DELETE /produk/:id      → produk#destroy

  # Batasi action yang dibuat
  resources :kategori, only: [:index, :show]
  resources :komentar, except: [:destroy]

  # Nested resources — pesanan milik pengguna
  resources :pengguna do
    resources :pesanan, shallow: true   # shallow: hindari URL terlalu dalam
  end

  # Member dan collection routes
  resources :produk do
    member do
      patch :aktifkan    # PATCH /produk/:id/aktifkan
      patch :nonaktifkan
    end

    collection do
      get :terlaris      # GET /produk/terlaris
      get :promo
    end
  end

  # Namespace — untuk API versioning
  namespace :api do
    namespace :v1 do
      resources :produk, only: [:index, :show, :create, :update]
    end
  end
  # GET /api/v1/produk → api/v1/produk#index

  # Route tunggal
  get "tentang",  to: "halaman#tentang", as: :tentang
  get "kontak",   to: "halaman#kontak"
  root "halaman#beranda"   # root route

  # Constraints
  get "admin/*path", to: "admin#index", constraints: { subdomain: "admin" }

  # Health check
  get "up" => "rails/health#show", as: :rails_health_check
end
# Lihat semua route
rails routes
rails routes --grep produk   # filter by pattern

ActiveRecord — Model dan Database #

Migrasi Schema #

# db/migrate/20240815000001_create_produk.rb
class CreateProduk < ActiveRecord::Migration[7.1]
  def change
    create_table :produk do |t|
      t.string   :nama,       null: false, limit: 200
      t.text     :deskripsi
      t.decimal  :harga,      null: false, precision: 15, scale: 2
      t.integer  :stok,       null: false, default: 0
      t.boolean  :aktif,      null: false, default: true
      t.string   :sku,        limit: 50
      t.string   :gambar_url
      t.references :kategori, null: false, foreign_key: true

      t.timestamps   # created_at dan updated_at
    end

    add_index :produk, :sku,        unique: true
    add_index :produk, :aktif
    add_index :produk, [:kategori_id, :aktif]
    add_index :produk, :nama
  end
end

Model ActiveRecord #

# app/models/produk.rb
class Produk < ApplicationRecord
  # Asosiasi
  belongs_to :kategori
  has_many   :item_pesanan, dependent: :destroy
  has_many   :pesanan, through: :item_pesanan
  has_many   :ulasan, dependent: :destroy
  has_one_attached :gambar    # Active Storage
  has_many_attached :foto_tambahan

  # Validasi
  validates :nama,  presence: true, length: { minimum: 2, maximum: 200 }
  validates :harga, presence: true, numericality: { greater_than: 0 }
  validates :stok,  numericality: { greater_than_or_equal_to: 0 }
  validates :sku,   uniqueness: true, allow_blank: true,
                    format: { with: /\ASKU-\w+\z/, message: "harus diawali SKU-" }

  # Scope
  scope :aktif,     -> { where(aktif: true) }
  scope :tersedia,  -> { aktif.where("stok > 0") }
  scope :terbaru,   -> { order(created_at: :desc) }
  scope :terlaris,  -> { joins(:item_pesanan).group(:id).order("COUNT(item_pesanan.id) DESC") }
  scope :dalam_harga, ->(min, maks) { where(harga: min..maks) }
  scope :cari,      ->(q) { where("nama ILIKE ?", "%#{q}%") }

  # Callback
  before_validation :normalisasi_nama
  before_save       :hitung_harga_diskon
  after_create      :log_produk_baru
  after_update      :invalidasi_cache, if: :saved_change_to_harga?
  after_destroy     :bersihkan_file

  # Enum
  enum status: { draft: 0, diterbitkan: 1, diarsipkan: 2 }

  # Delegasi
  delegate :nama, to: :kategori, prefix: true, allow_nil: true
  # produk.kategori_nama → kategori.nama

  # Method instance
  def tersedia?
    aktif? && stok > 0
  end

  def harga_format
    "Rp #{harga.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse}"
  end

  def kurangi_stok!(jumlah)
    raise "Stok tidak mencukupi" if stok < jumlah
    update!(stok: stok - jumlah)
  end

  # Method kelas
  def self.total_nilai_stok
    aktif.sum("harga * stok")
  end

  private

  def normalisasi_nama
    self.nama = nama.strip.squeeze(" ") if nama
  end

  def hitung_harga_diskon
    self.harga_diskon = harga * 0.9 if kategori&.sedang_promo?
  end

  def log_produk_baru
    Rails.logger.info "Produk baru: #{nama} (ID: #{id})"
  end

  def invalidasi_cache
    Rails.cache.delete("produk:#{id}")
  end

  def bersihkan_file
    gambar.purge_later if gambar.attached?
  end
end

Query ActiveRecord #

# Mencari record
Produk.find(1)                         # raise jika tidak ada
Produk.find_by(sku: "SKU-001")         # nil jika tidak ada
Produk.find_by!(sku: "SKU-001")        # raise jika tidak ada
Produk.where(aktif: true).first

# Kondisi
Produk.where(aktif: true)
Produk.where("harga > ?", 1_000_000)
Produk.where("nama ILIKE ?", "%laptop%")
Produk.where(kategori_id: [1, 2, 3])
Produk.where.not(aktif: false)

# Chaining scope
Produk.aktif.terbaru.limit(10).offset(20)
Produk.tersedia.dalam_harga(500_000, 5_000_000).cari("laptop")

# JOIN
Produk.joins(:kategori).where(kategori: { nama: "Elektronik" })
Produk.left_outer_joins(:ulasan).where(ulasan: { id: nil })   # produk tanpa ulasan

# Eager loading — cegah N+1
Produk.includes(:kategori, :ulasan)                # LEFT OUTER JOIN
Produk.preload(:kategori)                          # query terpisah
Produk.eager_load(:kategori)                       # INNER JOIN

# Agregasi
Produk.aktif.count
Produk.aktif.sum(:harga)
Produk.aktif.average(:harga)
Produk.aktif.minimum(:harga)
Produk.aktif.maximum(:harga)
Produk.group(:kategori_id).count
Produk.group(:status).having("count(*) > 5").count

# Update
Produk.where(aktif: false).update_all(stok: 0)   # langsung di DB
produk.update(harga: 15_000_000)
produk.update!(harga: 15_000_000)   # raise jika gagal

# Delete
produk.destroy                             # jalankan callback
Produk.where(stok: 0).destroy_all          # jalankan callback per record
Produk.where(stok: 0, aktif: false).delete_all  # langsung di DB, skip callback

ActionController #

# app/controllers/produk_controller.rb
class ProdukController < ApplicationController
  before_action :autentikasi_pengguna!
  before_action :set_produk, only: [:show, :edit, :update, :destroy, :aktifkan]
  before_action :otorisasi_admin!, only: [:new, :create, :edit, :update, :destroy]

  # GET /produk
  def index
    @produk = Produk.aktif.includes(:kategori).terbaru
    @produk = @produk.cari(params[:q]) if params[:q].present?
    @produk = @produk.dalam_harga(params[:min_harga], params[:max_harga]) if params[:min_harga].present?
    @produk = @produk.page(params[:page]).per(20)   # dengan pagy atau kaminari
  end

  # GET /produk/:id
  def show
    @ulasan = @produk.ulasan.terbaru.limit(5)
  end

  # GET /produk/new
  def new
    @produk = Produk.new
  end

  # POST /produk
  def create
    @produk = Produk.new(produk_params)

    if @produk.save
      redirect_to @produk, notice: "Produk berhasil dibuat"
    else
      render :new, status: :unprocessable_entity
    end
  end

  # PATCH /produk/:id
  def update
    if @produk.update(produk_params)
      redirect_to @produk, notice: "Produk berhasil diperbarui"
    else
      render :edit, status: :unprocessable_entity
    end
  end

  # DELETE /produk/:id
  def destroy
    @produk.destroy!
    redirect_to produk_path, notice: "Produk berhasil dihapus", status: :see_other
  end

  # PATCH /produk/:id/aktifkan
  def aktifkan
    @produk.update!(aktif: true)
    redirect_to @produk, notice: "Produk berhasil diaktifkan"
  end

  private

  def set_produk
    @produk = Produk.find(params[:id])
  rescue ActiveRecord::RecordNotFound
    redirect_to produk_path, alert: "Produk tidak ditemukan"
  end

  # Strong Parameters — whitelist parameter yang boleh masuk
  def produk_params
    params.require(:produk).permit(
      :nama, :deskripsi, :harga, :stok, :sku,
      :kategori_id, :aktif, :gambar,
      foto_tambahan: []
    )
  end

  def otorisasi_admin!
    redirect_to root_path, alert: "Akses ditolak" unless current_user.admin?
  end
end

ApplicationController — Filter dan Helper Bersama #

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include Pundit::Authorization   # otorisasi
  include Pagy::Backend           # pagination

  before_action :set_locale
  before_action :configure_permitted_parameters, if: :devise_controller?

  rescue_from ActiveRecord::RecordNotFound, with: :tidak_ditemukan
  rescue_from Pundit::NotAuthorizedError,   with: :akses_ditolak

  helper_method :current_user, :pengguna_login?

  private

  def autentikasi_pengguna!
    redirect_to login_path, alert: "Silakan login terlebih dahulu" unless pengguna_login?
  end

  def pengguna_login?
    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 tidak_ditemukan
    render file: Rails.root.join("public/404.html"), status: :not_found, layout: false
  end

  def akses_ditolak
    render file: Rails.root.join("public/403.html"), status: :forbidden, layout: false
  end

  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:sign_up, keys: [:nama, :telepon])
  end
end

API Controller — JSON Response #

# app/controllers/api/v1/produk_controller.rb
module Api
  module V1
    class ProdukController < Api::BaseController
      def index
        produk = Produk.aktif.includes(:kategori).terbaru
        render json: {
          status: "sukses",
          data:   produk.map { |p| serialisasi_produk(p) },
          meta:   { total: produk.count }
        }
      end

      def show
        produk = Produk.find(params[:id])
        render json: { status: "sukses", data: serialisasi_produk(produk) }
      rescue ActiveRecord::RecordNotFound
        render json: { status: "error", pesan: "Produk tidak ditemukan" },
               status: :not_found
      end

      def create
        produk = Produk.new(produk_params)
        if produk.save
          render json: { status: "sukses", data: serialisasi_produk(produk) },
                 status: :created
        else
          render json: { status: "error", errors: produk.errors },
                 status: :unprocessable_entity
        end
      end

      private

      def produk_params
        params.require(:produk).permit(:nama, :harga, :stok, :kategori_id)
      end

      def serialisasi_produk(p)
        {
          id:         p.id,
          nama:       p.nama,
          harga:      p.harga,
          stok:       p.stok,
          kategori:   p.kategori_nama,
          created_at: p.created_at.iso8601
        }
      end
    end
  end
end

Service Object — Logika Bisnis yang Kompleks #

Service Object memisahkan logika bisnis yang kompleks dari model dan controller:

# app/services/buat_pesanan_service.rb
class BuatPesananService
  Result = Struct.new(:sukses?, :pesanan, :error, keyword_init: true)

  def initialize(pengguna:, item_keranjang:, alamat_pengiriman:, metode_bayar:)
    @pengguna          = pengguna
    @item_keranjang    = item_keranjang
    @alamat_pengiriman = alamat_pengiriman
    @metode_bayar      = metode_bayar
  end

  def call
    validasi_stok!
    validasi_pengguna!

    pesanan = nil
    ActiveRecord::Base.transaction do
      pesanan = buat_pesanan
      kurangi_stok
      buat_transaksi_pembayaran(pesanan)
    end

    kirim_konfirmasi(pesanan)
    Result.new(sukses?: true, pesanan: pesanan)

  rescue StokHabisError => e
    Result.new(sukses?: false, error: e.message)
  rescue PembayaranGagalError => e
    Result.new(sukses?: false, error: "Pembayaran gagal: #{e.message}")
  rescue => e
    Rails.logger.error "BuatPesananService error: #{e.message}"
    Result.new(sukses?: false, error: "Terjadi kesalahan, coba lagi")
  end

  private

  def validasi_stok!
    @item_keranjang.each do |item|
      produk = Produk.lock.find(item[:produk_id])
      raise StokHabisError, "#{produk.nama} stok habis" if produk.stok < item[:jumlah]
    end
  end

  def validasi_pengguna!
    raise "Pengguna belum terverifikasi" unless @pengguna.email_terverifikasi?
  end

  def buat_pesanan
    total = @item_keranjang.sum { |i| Produk.find(i[:produk_id]).harga * i[:jumlah] }
    pesanan = Pesanan.create!(
      pengguna:          @pengguna,
      total:             total,
      status:            :pending,
      alamat_pengiriman: @alamat_pengiriman
    )

    @item_keranjang.each do |item|
      pesanan.item_pesanan.create!(
        produk_id: item[:produk_id],
        jumlah:    item[:jumlah],
        harga:     Produk.find(item[:produk_id]).harga
      )
    end

    pesanan
  end

  def kurangi_stok
    @item_keranjang.each do |item|
      Produk.find(item[:produk_id]).kurangi_stok!(item[:jumlah])
    end
  end

  def buat_transaksi_pembayaran(pesanan)
    PembayaranService.new(@metode_bayar).charge(pesanan.total)
  end

  def kirim_konfirmasi(pesanan)
    NotifikasiMailer.pesanan_dikonfirmasi(@pengguna, pesanan).deliver_later
  end
end

# Penggunaan di controller
def create
  result = BuatPesananService.new(
    pengguna:          current_user,
    item_keranjang:    session[:keranjang],
    alamat_pengiriman: alamat_params,
    metode_bayar:      params[:metode_bayar]
  ).call

  if result.sukses?
    redirect_to result.pesanan, notice: "Pesanan berhasil dibuat!"
  else
    flash[:alert] = result.error
    render :checkout
  end
end

ActionMailer #

# app/mailers/notifikasi_mailer.rb
class NotifikasiMailer < ApplicationMailer
  default from: "[email protected]"

  def pesanan_dikonfirmasi(pengguna, pesanan)
    @pengguna = pengguna
    @pesanan  = pesanan

    mail(
      to:      pengguna.email,
      subject: "Pesanan ##{pesanan.id} Dikonfirmasi"
    )
  end

  def selamat_datang(pengguna)
    @pengguna = pengguna
    @url      = root_url

    attachments["panduan.pdf"] = File.read(Rails.root.join("public/panduan.pdf"))

    mail(to: pengguna.email, subject: "Selamat Datang di Toko!")
  end
end

# Kirim email
NotifikasiMailer.pesanan_dikonfirmasi(user, pesanan).deliver_now   # sinkron
NotifikasiMailer.pesanan_dikonfirmasi(user, pesanan).deliver_later  # asinkron via ActiveJob

ActiveJob — Background Job #

# app/jobs/kirim_email_job.rb
class KirimEmailJob < 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)
    NotifikasiMailer.send(template, user, *args).deliver_now
  end
end

# Enqueue
KirimEmailJob.perform_later(user.id, :selamat_datang)
KirimEmailJob.set(wait: 1.hour).perform_later(user.id, :pengingat)
KirimEmailJob.set(queue: :prioritas).perform_later(user.id, :penting)

Concern — Modul yang Dapat Digunakan Ulang #

# app/models/concerns/dapat_dicari.rb
module DapatDicari
  extend ActiveSupport::Concern

  included do
    scope :cari, ->(q) { where("nama ILIKE ?", "%#{sanitize_sql_like(q)}%") }
  end

  class_methods do
    def cari_lengkap(q)
      left_outer_joins(:tag)
        .where("nama ILIKE :q OR deskripsi ILIKE :q OR tag.nama ILIKE :q", q: "%#{q}%")
        .distinct
    end
  end

  def cocok_dengan?(kata_kunci)
    nama.downcase.include?(kata_kunci.downcase)
  end
end

# app/models/concerns/dapat_di_audit.rb
module DapatDiAudit
  extend ActiveSupport::Concern

  included do
    has_many :audit_logs, as: :auditable, dependent: :destroy
    after_create  { catat_perubahan(:create) }
    after_update  { catat_perubahan(:update, changed_attributes) }
    after_destroy { catat_perubahan(:destroy) }
  end

  private

  def catat_perubahan(aksi, perubahan = {})
    audit_logs.create!(
      aksi:       aksi,
      perubahan:  perubahan,
      pengguna:   Current.user,
      ip_address: Current.ip_address
    )
  end
end

# Gunakan di model
class Produk < ApplicationRecord
  include DapatDicari
  include DapatDiAudit
end

Kredensial Terenkripsi #

Rails menyediakan sistem kredensial terenkripsi untuk menyimpan secret dengan aman:

# Edit kredensial
EDITOR=nano rails credentials:edit

# Edit per environment
EDITOR=nano rails credentials:edit --environment production
# config/credentials.yml.enc (isi setelah di-decrypt)
secret_key_base: "panjang_sekali_..."
database:
  password: "db_password_production"
stripe:
  secret_key: "sk_live_..."
  public_key:  "pk_live_..."
midtrans:
  server_key: "Mid-server-..."
  client_key: "Mid-client-..."
# Akses di aplikasi
Rails.application.credentials.stripe[:secret_key]
Rails.application.credentials.dig(:midtrans, :server_key)
Rails.application.credentials.database[:password]

ActionCable — Real-Time #

# app/channels/notifikasi_channel.rb
class NotifikasiChannel < ApplicationCable::Channel
  def subscribed
    stream_for current_user
  end

  def unsubscribed; end
end

# Broadcast dari mana saja
NotifikasiChannel.broadcast_to(
  user,
  { tipe: "pesanan_baru", pesan: "Pesanan Anda sudah diterima!" }
)

Struktur Aplikasi yang Baik #

app/
├── controllers/
│   ├── concerns/           ← Concern untuk controller
│   ├── api/v1/             ← API controllers
│   └── admin/              ← Admin controllers
├── models/
│   ├── concerns/           ← Concern untuk model
│   └── *.rb
├── services/               ← Service Objects (logika bisnis)
├── queries/                ← Query Objects (query kompleks)
├── policies/               ← Pundit policies (otorisasi)
├── serializers/            ← JSON serializers
├── forms/                  ← Form Objects (validasi form kompleks)
├── jobs/                   ← Background jobs
├── mailers/                ← Email
└── channels/               ← WebSocket

Ringkasan #

  • Convention over Configuration — ikuti konvensi Rails (snake_case file, PascalCase class) dan kamu tidak perlu konfigurasi apapun; melawan konvensi justru menambah kerumitan.
  • Strong Parameters wajib — selalu gunakan params.require().permit() di controller; tanpa ini semua parameter dari user bisa masuk ke database (mass assignment vulnerability).
  • N+1 query adalah musuh utama — selalu gunakan includes, preload, atau eager_load saat mengakses asosiasi dalam loop; gunakan Bullet gem untuk mendeteksi N+1 secara otomatis.
  • Service Object untuk logika bisnis kompleks — jika method di controller atau model panjangnya lebih dari 10 baris, pertimbangkan memindahkannya ke Service Object; lebih mudah ditest dan digunakan ulang.
  • Concern untuk kode yang dipakai banyak modelinclude Searchable, include Auditable lebih bersih dari copy-paste kode yang sama ke banyak model.
  • deliver_later bukan deliver_now untuk email — email yang dikirim sinkron memperlambat response; selalu kirim via ActiveJob dengan deliver_later.
  • Kredensial terenkripsi untuk semua secret — jangan simpan API key, database password, atau secret lain di .env yang di-commit; gunakan rails credentials:edit.
  • update_all dan delete_all skip callback — gunakan hanya jika memang tidak perlu callback; jika ada callback penting (cache invalidation, audit log), gunakan each.update atau each.destroy.
  • Scope untuk query yang sering dipakaiProduk.aktif.terbaru.limit(10) jauh lebih bersih dan reusable dari Produk.where(aktif: true).order(created_at: :desc).limit(10).
  • rescue_from di ApplicationController — tangani exception umum (RecordNotFound, NotAuthorized) di satu tempat agar semua controller dapat perilaku yang konsisten.

← Sebelumnya: Memcached   Berikutnya: Sinatra →

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