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, ataueager_loadsaat 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 model —
include Searchable,include Auditablelebih bersih dari copy-paste kode yang sama ke banyak model.deliver_laterbukandeliver_nowuntuk email — email yang dikirim sinkron memperlambat response; selalu kirim via ActiveJob dengandeliver_later.- Kredensial terenkripsi untuk semua secret — jangan simpan API key, database password, atau secret lain di
.envyang di-commit; gunakanrails credentials:edit.update_alldandelete_allskip callback — gunakan hanya jika memang tidak perlu callback; jika ada callback penting (cache invalidation, audit log), gunakaneach.updateataueach.destroy.- Scope untuk query yang sering dipakai —
Produk.aktif.terbaru.limit(10)jauh lebih bersih dan reusable dariProduk.where(aktif: true).order(created_at: :desc).limit(10).rescue_fromdi ApplicationController — tangani exception umum (RecordNotFound, NotAuthorized) di satu tempat agar semua controller dapat perilaku yang konsisten.