Pertanyaan Wawancara Ruby on Rails: Top 25 di 2026

25 pertanyaan wawancara Ruby on Rails yang paling sering ditanyakan. Arsitektur MVC, Active Record, migrasi, pengujian RSpec, REST API dengan jawaban detail dan contoh kode.

Pertanyaan Wawancara Ruby on Rails - Panduan Lengkap

Wawancara Ruby on Rails menilai penguasaan framework Ruby paling populer, pemahaman arsitektur MVC, ORM Active Record, dan kemampuan membangun aplikasi web yang kokoh dengan filosofi "Convention over Configuration". Panduan ini mencakup 25 pertanyaan yang paling sering ditanyakan, dari dasar Rails hingga pola produksi tingkat lanjut.

Tips wawancara

Recruiter menghargai kandidat yang memahami filosofi Rails: "Convention over Configuration", DRY (Don't Repeat Yourself), dan pola Rails Way. Menjelaskan mengapa Rails membuat keputusan arsitektur tertentu dapat membuat perbedaan.

Dasar-dasar Ruby on Rails

Pertanyaan 1: Jelaskan pola MVC di Ruby on Rails

Pola Model-View-Controller (MVC) merupakan inti arsitektur Rails. Pola ini memisahkan tanggung jawab menjadi tiga lapisan berbeda untuk meningkatkan keterbacaan dan kemudahan pengujian kode.

ruby
# app/models/article.rb
# Model mengelola data dan logika bisnis
class Article < ApplicationRecord
  # Validasi data
  validates :title, presence: true, length: { minimum: 5 }
  validates :body, presence: true

  # Asosiasi dengan model lain
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy
  has_many :tags, through: :article_tags

  # Scope untuk query yang dapat digunakan ulang
  scope :published, -> { where(published: true) }
  scope :recent, -> { order(created_at: :desc).limit(10) }

  # Callback siklus hidup
  before_save :generate_slug

  private

  def generate_slug
    self.slug = title.parameterize if title_changed?
  end
end
ruby
# app/controllers/articles_controller.rb
# Controller menerima permintaan dan mengatur respons
class ArticlesController < ApplicationController
  before_action :authenticate_user!, except: [:index, :show]
  before_action :set_article, only: [:show, :edit, :update, :destroy]

  def index
    @articles = Article.published.recent.includes(:author)
  end

  def show
    @comments = @article.comments.includes(:user)
  end

  def create
    @article = current_user.articles.build(article_params)

    if @article.save
      redirect_to @article, notice: 'Artikel berhasil dibuat.'
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def set_article
    @article = Article.find(params[:id])
  end

  def article_params
    params.require(:article).permit(:title, :body, :published, tag_ids: [])
  end
end
erb
<%# app/views/articles/show.html.erb %>
<%# View menampilkan data dalam format HTML %>
<article class="article-detail">
  <header>
    <h1><%= @article.title %></h1>
    <p class="meta">
      Oleh <%= @article.author.name %>      <%= l @article.created_at, format: :long %>
    </p>
  </header>

  <div class="content">
    <%= simple_format @article.body %>
  </div>

  <%# Partial untuk komentar %>
  <%= render @comments %>
</article>

Alur tipikal: permintaan tiba di Router, yang meneruskannya ke Controller yang sesuai. Controller berinteraksi dengan Model untuk mengambil atau memodifikasi data, lalu meneruskan data tersebut ke View untuk render HTML.

Pertanyaan 2: Apa itu Active Record dan bagaimana ORM Rails bekerja?

Active Record adalah ORM (Object-Relational Mapping) Rails yang mengimplementasikan pola Active Record. Setiap kelas Model mewakili sebuah tabel basis data, dan setiap instance mewakili satu baris.

ruby
# app/models/user.rb
# Active Record memetakan kolom ke atribut secara otomatis
class User < ApplicationRecord
  # Tabel 'users' diasosiasikan secara otomatis
  # Kolom: id, email, name, created_at, updated_at

  has_secure_password # BCrypt untuk kata sandi

  has_many :articles, foreign_key: :author_id
  has_one :profile, dependent: :destroy
  has_and_belongs_to_many :roles

  # Validasi
  validates :email, presence: true,
                    uniqueness: { case_sensitive: false },
                    format: { with: URI::MailTo::EMAIL_REGEXP }

  # Callback
  before_save :normalize_email

  # Metode kelas untuk query
  def self.admins
    joins(:roles).where(roles: { name: 'admin' })
  end

  private

  def normalize_email
    self.email = email.downcase.strip
  end
end
ruby
# Contoh query Active Record
# Konsol Rails atau di dalam service

# Pembuatan
user = User.create!(email: 'dev@example.com', name: 'Alice', password: 'secret123')

# Pembacaan dengan kondisi
active_users = User.where(active: true).order(:name)
user = User.find_by(email: 'dev@example.com')

# Query berantai (lazy evaluation)
recent_admins = User.admins
                    .where('created_at > ?', 1.month.ago)
                    .includes(:profile)
                    .limit(10)

# Pencegahan N+1 dengan eager loading
articles = Article.includes(:author, :comments).published

# Pembaruan
user.update!(name: 'Alice Martin')

# Transaksi
User.transaction do
  user.debit_balance!(100)
  recipient.credit_balance!(100)
  Payment.create!(from: user, to: recipient, amount: 100)
end

Active Record mengubah metode Ruby menjadi query SQL yang dioptimalkan. Metode seperti where, joins, dan includes bersifat lazy: query baru dieksekusi saat iterasi atau saat memanggil to_a.

Pertanyaan 3: Jelaskan sistem migrasi Rails

Migrasi memungkinkan versi skema basis data dengan Ruby. Migrasi bersifat reversible dan memungkinkan evolusi struktur data yang terkendali.

ruby
# db/migrate/20260203100000_create_products.rb
# Migrasi untuk membuat tabel
class CreateProducts < ActiveRecord::Migration[7.1]
  def change
    create_table :products do |t|
      t.string :name, null: false
      t.text :description
      t.decimal :price, precision: 10, scale: 2, null: false
      t.integer :stock_quantity, default: 0
      t.references :category, null: false, foreign_key: true
      t.boolean :active, default: true

      t.timestamps # created_at dan updated_at otomatis
    end

    # Indeks untuk performa
    add_index :products, :name
    add_index :products, [:category_id, :active]
  end
end
ruby
# db/migrate/20260203110000_add_slug_to_products.rb
# Migrasi untuk memodifikasi tabel yang sudah ada
class AddSlugToProducts < ActiveRecord::Migration[7.1]
  def change
    add_column :products, :slug, :string
    add_index :products, :slug, unique: true

    # Mengisi slug yang sudah ada
    reversible do |dir|
      dir.up do
        Product.find_each do |product|
          product.update_column(:slug, product.name.parameterize)
        end
      end
    end

    # Menjadikan NOT NULL setelah pengisian
    change_column_null :products, :slug, false
  end
end
bash
# Perintah migrasi penting
rails db:migrate              # Menjalankan migrasi yang tertunda
rails db:rollback             # Membatalkan migrasi terakhir
rails db:rollback STEP=3      # Membatalkan 3 migrasi terakhir
rails db:migrate:status       # Melihat status migrasi
rails db:seed                 # Menjalankan db/seeds.rb
rails db:reset                # Drop, create, migrate, seed

Migrasi harus reversible. Metode change cerdas dan dapat membalik operasi umum secara otomatis. Untuk kasus kompleks, gunakan up dan down secara terpisah.

Active Record tingkat lanjut

Pertanyaan 4: Bagaimana mengoptimalkan query N+1 di Rails?

Masalah N+1 muncul ketika query awal diikuti oleh N query tambahan untuk memuat asosiasi. Rails menyediakan beberapa metode eager loading untuk mengatasi masalah ini.

ruby
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def index
    # ❌ MASALAH N+1: 1 query + N query per pesanan
    # @orders = Order.all
    # Di view: order.user.name menghasilkan satu query per pesanan

    # ✅ SOLUSI dengan includes (eager loading)
    @orders = Order.includes(:user, :items)
                   .where(status: 'completed')
                   .order(created_at: :desc)
    # Hanya menghasilkan 3 query secara total
  end

  def show
    # includes: memuat asosiasi terpisah (2-3 query)
    @order = Order.includes(items: :product).find(params[:id])

    # preload: memaksa pemuatan terpisah
    @order = Order.preload(:items, :user).find(params[:id])

    # eager_load: memaksa LEFT OUTER JOIN (1 query)
    @order = Order.eager_load(:items).find(params[:id])
  end
end
ruby
# app/models/order.rb
class Order < ApplicationRecord
  belongs_to :user
  has_many :items, class_name: 'OrderItem'
  has_many :products, through: :items

  # Scope dengan includes default
  scope :with_details, -> { includes(:user, items: :product) }

  # Counter cache untuk menghindari query COUNT
  # Memerlukan: add_column :users, :orders_count, :integer, default: 0
  belongs_to :user, counter_cache: true
end
ruby
# Deteksi N+1 dengan gem Bullet (development)
# config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.alert = true
  Bullet.bullet_logger = true
  Bullet.rails_logger = true
end

# Bullet akan menampilkan peringatan ketika:
# - Query N+1 terdeteksi
# - Eager loading tidak diperlukan
# - Counter cache seharusnya digunakan

Aturannya: gunakan includes secara default (Rails memilih strategi optimal), preload saat ingin memaksa query terpisah, eager_load saat memfilter berdasarkan asosiasi.

Pertanyaan 5: Jelaskan Scope dan Query Object di Rails

Scope mengenkapsulasi kondisi query yang dapat digunakan ulang. Untuk query kompleks, Query Object menawarkan organisasi dan keterujian yang lebih baik.

ruby
# app/models/product.rb
class Product < ApplicationRecord
  # Scope sederhana
  scope :active, -> { where(active: true) }
  scope :in_stock, -> { where('stock_quantity > 0') }
  scope :featured, -> { where(featured: true) }

  # Scope dengan parameter
  scope :cheaper_than, ->(price) { where('price < ?', price) }
  scope :in_category, ->(category) { where(category: category) }

  # Scope berantai
  scope :available, -> { active.in_stock }

  # Scope dengan joins
  scope :with_recent_orders, -> {
    joins(:order_items)
      .where('order_items.created_at > ?', 30.days.ago)
      .distinct
  }

  # Scope dengan subquery
  scope :bestsellers, -> {
    where(id: OrderItem.group(:product_id)
                       .order('COUNT(*) DESC')
                       .limit(10)
                       .select(:product_id))
  }
end
ruby
# app/queries/products_search_query.rb
# Query Object untuk pencarian kompleks
class ProductsSearchQuery
  def initialize(relation = Product.all)
    @relation = relation
  end

  def call(params)
    @relation = filter_by_category(params[:category])
    @relation = filter_by_price_range(params[:min_price], params[:max_price])
    @relation = filter_by_search(params[:q])
    @relation = apply_sorting(params[:sort])
    @relation
  end

  private

  def filter_by_category(category)
    return @relation if category.blank?
    @relation.where(category_id: category)
  end

  def filter_by_price_range(min, max)
    @relation = @relation.where('price >= ?', min) if min.present?
    @relation = @relation.where('price <= ?', max) if max.present?
    @relation
  end

  def filter_by_search(query)
    return @relation if query.blank?
    @relation.where('name ILIKE ? OR description ILIKE ?',
                    "%#{query}%", "%#{query}%")
  end

  def apply_sorting(sort)
    case sort
    when 'price_asc' then @relation.order(price: :asc)
    when 'price_desc' then @relation.order(price: :desc)
    when 'newest' then @relation.order(created_at: :desc)
    else @relation.order(:name)
    end
  end
end

# Penggunaan di controller
@products = ProductsSearchQuery.new(Product.active).call(params)

Scope sangat cocok untuk kondisi sederhana dan dapat digunakan ulang. Query Object cocok untuk pencarian kompleks dengan banyak filter opsional dan logika komposisi.

Siap menguasai wawancara Ruby on Rails Anda?

Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.

Routing dan Controller

Pertanyaan 6: Bagaimana cara kerja routing RESTful di Rails?

Rails mendorong rute RESTful yang memetakan kata kerja HTTP ke aksi CRUD. Router menerjemahkan URL menjadi panggilan controller spesifik.

ruby
# config/routes.rb
Rails.application.routes.draw do
  # Rute RESTful standar (7 aksi)
  resources :articles do
    # Rute bersarang
    resources :comments, only: [:create, :destroy]

    # Rute member (bertindak pada satu instance)
    member do
      post :publish
      delete :archive
    end

    # Rute collection (bertindak pada koleksi)
    collection do
      get :drafts
      get :search
    end
  end

  # Rute API dengan namespace
  namespace :api do
    namespace :v1 do
      resources :products, only: [:index, :show, :create, :update] do
        resources :reviews, shallow: true
      end
    end
  end

  # Rute kustom
  get 'dashboard', to: 'dashboard#index'

  # Pembatasan rute
  constraints(SubdomainConstraint.new) do
    resources :admin_settings
  end

  # Rute root
  root 'home#index'
end
bash
# rails routes - Menampilkan semua rute yang dihasilkan
#
# Verb   URI Pattern                    Controller#Action
# GET    /articles                      articles#index
# POST   /articles                      articles#create
# GET    /articles/new                  articles#new
# GET    /articles/:id/edit             articles#edit
# GET    /articles/:id                  articles#show
# PATCH  /articles/:id                  articles#update
# DELETE /articles/:id                  articles#destroy
# POST   /articles/:id/publish          articles#publish
# GET    /articles/drafts               articles#drafts

Helper rute yang dihasilkan (article_path(@article), new_article_path) memungkinkan referensi URL secara dinamis dan mudah dipelihara.

Pertanyaan 7: Jelaskan callback dan filter di controller

Callback (before_action, after_action, around_action) memungkinkan eksekusi kode sebelum, sesudah, atau di sekitar aksi controller.

ruby
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # Perlindungan CSRF aktif secara default
  protect_from_forgery with: :exception

  # Callback global untuk autentikasi
  before_action :authenticate_user!

  # Penanganan error global
  rescue_from ActiveRecord::RecordNotFound, with: :not_found
  rescue_from ActionController::ParameterMissing, with: :bad_request

  private

  def not_found
    render json: { error: 'Sumber daya tidak ditemukan' }, status: :not_found
  end

  def bad_request(exception)
    render json: { error: exception.message }, status: :bad_request
  end
end
ruby
# app/controllers/admin/products_controller.rb
class Admin::ProductsController < ApplicationController
  # Callback dengan opsi
  before_action :require_admin
  before_action :set_product, only: [:show, :edit, :update, :destroy]
  after_action :log_activity, only: [:create, :update, :destroy]

  # Callback bersyarat
  before_action :check_stock, only: [:update], if: :stock_changed?

  def create
    @product = Product.new(product_params)

    if @product.save
      redirect_to [:admin, @product], notice: 'Produk dibuat.'
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @product.update(product_params)
      redirect_to [:admin, @product], notice: 'Produk diperbarui.'
    else
      render :edit, status: :unprocessable_entity
    end
  end

  private

  def require_admin
    redirect_to root_path unless current_user&.admin?
  end

  def set_product
    @product = Product.find(params[:id])
  end

  def stock_changed?
    params[:product][:stock_quantity].present?
  end

  def log_activity
    ActivityLog.create!(
      user: current_user,
      action: action_name,
      resource: @product
    )
  end

  def product_params
    params.require(:product).permit(:name, :price, :description, :stock_quantity)
  end
end

Callback dieksekusi sesuai urutan deklarasi. Gunakan skip_before_action di subclass untuk menonaktifkan callback yang diwariskan. Hindari callback dengan terlalu banyak logika bisnis: lebih baik gunakan Service Object.

Service dan arsitektur

Pertanyaan 8: Bagaimana mengimplementasikan Service Object di Rails?

Service Object mengenkapsulasi logika bisnis kompleks yang bukan milik Model maupun Controller. Service Object meningkatkan keterujian dan mengikuti prinsip single responsibility.

ruby
# app/services/order_processor.rb
# Service Object dengan antarmuka standar
class OrderProcessor
  def initialize(order, payment_method:)
    @order = order
    @payment_method = payment_method
  end

  def call
    return failure('Pesanan sudah diproses') if @order.processed?

    ActiveRecord::Base.transaction do
      validate_stock!
      process_payment!
      update_inventory!
      send_confirmation!

      @order.update!(status: 'completed', processed_at: Time.current)
    end

    success(@order)
  rescue PaymentError => e
    failure("Pembayaran gagal: #{e.message}")
  rescue InsufficientStockError => e
    failure("Stok tidak cukup: #{e.message}")
  end

  private

  def validate_stock!
    @order.items.each do |item|
      unless item.product.stock_quantity >= item.quantity
        raise InsufficientStockError, item.product.name
      end
    end
  end

  def process_payment!
    result = PaymentGateway.charge(
      amount: @order.total,
      method: @payment_method,
      description: "Pesanan ##{@order.id}"
    )

    raise PaymentError, result.error unless result.success?
    @order.update!(payment_reference: result.transaction_id)
  end

  def update_inventory!
    @order.items.each do |item|
      item.product.decrement!(:stock_quantity, item.quantity)
    end
  end

  def send_confirmation!
    OrderMailer.confirmation(@order).deliver_later
  end

  def success(data)
    Result.new(success: true, data: data)
  end

  def failure(error)
    Result.new(success: false, error: error)
  end

  Result = Struct.new(:success, :data, :error, keyword_init: true) do
    def success? = success
    def failure? = !success
  end
end
ruby
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def create
    @order = current_user.orders.build(order_params)

    if @order.save
      result = OrderProcessor.new(@order, payment_method: params[:payment_method]).call

      if result.success?
        redirect_to @order, notice: 'Pesanan dikonfirmasi!'
      else
        @order.update!(status: 'payment_failed')
        flash.now[:alert] = result.error
        render :new, status: :unprocessable_entity
      end
    else
      render :new, status: :unprocessable_entity
    end
  end
end

Pola Service Object mengikuti konvensi sederhana: satu kelas, satu tanggung jawab, satu metode publik call. Mengembalikan objek Result memungkinkan penanganan keberhasilan dan kegagalan yang rapi.

Pertanyaan 9: Jelaskan Concerns di Rails

Concerns memungkinkan ekstraksi dan berbagi kode antara Model atau Controller. Concerns menggunakan ActiveSupport::Concern untuk sintaks include yang bersih.

ruby
# app/models/concerns/sluggable.rb
# Concern yang dapat digunakan ulang untuk menghasilkan slug
module Sluggable
  extend ActiveSupport::Concern

  included do
    # Kode yang dijalankan saat di-include
    before_validation :generate_slug, if: :should_generate_slug?
    validates :slug, presence: true, uniqueness: true
  end

  # Metode kelas
  class_methods do
    def find_by_slug!(slug)
      find_by!(slug: slug)
    end

    def sluggable_source(column = :title)
      @sluggable_source = column
    end

    def sluggable_source_column
      @sluggable_source || :title
    end
  end

  # Metode instance
  def to_param
    slug
  end

  private

  def should_generate_slug?
    slug.blank? || send("#{self.class.sluggable_source_column}_changed?")
  end

  def generate_slug
    source = send(self.class.sluggable_source_column)
    return if source.blank?

    base_slug = source.parameterize
    self.slug = unique_slug(base_slug)
  end

  def unique_slug(base)
    slug = base
    counter = 1

    while self.class.where(slug: slug).where.not(id: id).exists?
      slug = "#{base}-#{counter}"
      counter += 1
    end

    slug
  end
end
ruby
# app/models/article.rb
class Article < ApplicationRecord
  include Sluggable

  sluggable_source :title # Opsional, default :title
end

# app/models/product.rb
class Product < ApplicationRecord
  include Sluggable

  sluggable_source :name
end
ruby
# app/controllers/concerns/pagination.rb
# Concern untuk controller
module Pagination
  extend ActiveSupport::Concern

  included do
    helper_method :page_param, :per_page_param
  end

  private

  def paginate(relation)
    relation.page(page_param).per(per_page_param)
  end

  def page_param
    params[:page]&.to_i || 1
  end

  def per_page_param
    [params[:per_page]&.to_i || 25, 100].min
  end
end

Concerns berguna untuk kode yang benar-benar dibagikan. Hindari membuat Concern hanya untuk "memperpendek" sebuah Model: itu menyembunyikan kompleksitas tanpa menguranginya.

Pengujian dengan RSpec

Pertanyaan 10: Bagaimana menyusun tes RSpec di Rails?

RSpec adalah framework testing standar untuk Rails. Struktur tes yang baik mencakup Model spec, Controller spec, Service spec, dan tes integrasi.

ruby
# spec/models/user_spec.rb
require 'rails_helper'

RSpec.describe User, type: :model do
  # Factory dengan FactoryBot
  let(:user) { build(:user) }
  let(:admin) { build(:user, :admin) }

  describe 'validations' do
    it { is_expected.to validate_presence_of(:email) }
    it { is_expected.to validate_uniqueness_of(:email).case_insensitive }

    it 'memvalidasi format email' do
      user.email = 'invalid'
      expect(user).not_to be_valid
      expect(user.errors[:email]).to include('is invalid')
    end
  end

  describe 'associations' do
    it { is_expected.to have_many(:articles).dependent(:destroy) }
    it { is_expected.to have_one(:profile) }
    it { is_expected.to belong_to(:organization).optional }
  end

  describe '#full_name' do
    it 'mengembalikan nama depan dan belakang yang digabung' do
      user = build(:user, first_name: 'John', last_name: 'Doe')
      expect(user.full_name).to eq('John Doe')
    end

    it 'menangani nama belakang yang kosong' do
      user = build(:user, first_name: 'John', last_name: nil)
      expect(user.full_name).to eq('John')
    end
  end

  describe '.active' do
    it 'mengembalikan hanya pengguna aktif' do
      active = create(:user, active: true)
      inactive = create(:user, active: false)

      expect(User.active).to include(active)
      expect(User.active).not_to include(inactive)
    end
  end
end
ruby
# spec/services/order_processor_spec.rb
require 'rails_helper'

RSpec.describe OrderProcessor do
  let(:user) { create(:user) }
  let(:product) { create(:product, stock_quantity: 10, price: 100) }
  let(:order) { create(:order, user: user, items: [build(:order_item, product: product, quantity: 2)]) }

  subject { described_class.new(order, payment_method: 'card') }

  describe '#call' do
    context 'ketika pesanan valid' do
      before do
        allow(PaymentGateway).to receive(:charge).and_return(
          OpenStruct.new(success?: true, transaction_id: 'txn_123')
        )
      end

      it 'memproses pesanan dengan sukses' do
        result = subject.call

        expect(result).to be_success
        expect(order.reload.status).to eq('completed')
      end

      it 'mengurangi stok produk' do
        expect { subject.call }.to change { product.reload.stock_quantity }.by(-2)
      end

      it 'mengirim email konfirmasi' do
        expect { subject.call }
          .to have_enqueued_mail(OrderMailer, :confirmation)
          .with(order)
      end
    end

    context 'ketika pembayaran gagal' do
      before do
        allow(PaymentGateway).to receive(:charge).and_return(
          OpenStruct.new(success?: false, error: 'Card declined')
        )
      end

      it 'mengembalikan hasil kegagalan' do
        result = subject.call

        expect(result).to be_failure
        expect(result.error).to include('Card declined')
      end

      it 'tidak memperbarui status pesanan' do
        expect { subject.call }.not_to change { order.reload.status }
      end
    end
  end
end
ruby
# spec/requests/api/v1/products_spec.rb
require 'rails_helper'

RSpec.describe 'API V1 Products', type: :request do
  let(:user) { create(:user) }
  let(:headers) { { 'Authorization' => "Bearer #{user.api_token}" } }

  describe 'GET /api/v1/products' do
    let!(:products) { create_list(:product, 3, :active) }

    it 'mengembalikan daftar produk' do
      get '/api/v1/products', headers: headers

      expect(response).to have_http_status(:ok)
      expect(json_response['data'].size).to eq(3)
    end

    it 'memfilter berdasarkan kategori' do
      category = create(:category)
      categorized = create(:product, category: category)

      get '/api/v1/products', params: { category_id: category.id }, headers: headers

      expect(json_response['data'].map { |p| p['id'] }).to eq([categorized.id])
    end
  end

  describe 'POST /api/v1/products' do
    let(:valid_params) do
      { product: { name: 'Produk Baru', price: 99.99, category_id: create(:category).id } }
    end

    it 'membuat produk baru' do
      expect {
        post '/api/v1/products', params: valid_params, headers: headers
      }.to change(Product, :count).by(1)

      expect(response).to have_http_status(:created)
    end
  end
end

Praktik terbaik: gunakan let untuk data, describe untuk metode/konteks, context untuk kondisi, dan it untuk asersi spesifik. Satu tes harus menguji satu hal.

Pertanyaan 11: Bagaimana menggunakan factory dengan FactoryBot?

FactoryBot memungkinkan pembuatan data uji secara deklaratif dan mudah dipelihara. Factory menggantikan fixture statis.

ruby
# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    # Sequence untuk memastikan keunikan
    sequence(:email) { |n| "user#{n}@example.com" }
    first_name { Faker::Name.first_name }
    last_name { Faker::Name.last_name }
    password { 'password123' }
    confirmed_at { Time.current }

    # Trait untuk variasi
    trait :admin do
      role { 'admin' }
      after(:create) do |user|
        user.permissions.create!(name: 'admin_access')
      end
    end

    trait :unconfirmed do
      confirmed_at { nil }
    end

    trait :with_profile do
      after(:create) do |user|
        create(:profile, user: user)
      end
    end

    trait :with_articles do
      transient do
        articles_count { 3 }
      end

      after(:create) do |user, evaluator|
        create_list(:article, evaluator.articles_count, author: user)
      end
    end

    # Factory turunan
    factory :admin_user do
      admin
      with_profile
    end
  end
end
ruby
# spec/factories/orders.rb
FactoryBot.define do
  factory :order do
    user
    status { 'pending' }

    trait :with_items do
      transient do
        items_count { 2 }
      end

      after(:create) do |order, evaluator|
        create_list(:order_item, evaluator.items_count, order: order)
        order.recalculate_total!
      end
    end

    trait :completed do
      status { 'completed' }
      processed_at { Time.current }
      with_items
    end

    trait :high_value do
      after(:create) do |order|
        create(:order_item, order: order, quantity: 10, unit_price: 500)
        order.recalculate_total!
      end
    end
  end
end
ruby
# Penggunaan dalam tes
RSpec.describe OrderProcessor do
  # build: instance tidak persisten
  let(:user) { build(:user) }

  # create: persisten ke DB
  let(:order) { create(:order, :with_items, user: user) }

  # create_list: beberapa instance
  let(:products) { create_list(:product, 5) }

  # Menggabungkan trait
  let(:admin) { create(:user, :admin, :with_profile) }

  # Mengganti atribut
  let(:expensive_order) { create(:order, :with_items, items_count: 10) }

  # build_stubbed: lebih cepat, untuk unit test
  let(:stubbed_user) { build_stubbed(:user) }
end

Lebih baik menggunakan build atau build_stubbed daripada create ketika persistensi tidak diperlukan: ini mempercepat tes secara signifikan.

Background Job

Pertanyaan 12: Bagaimana menggunakan Active Job dan Sidekiq di Rails?

Active Job menyediakan antarmuka terpadu untuk pekerjaan latar belakang, terlepas dari backend (Sidekiq, Resque, dll.). Sidekiq adalah pilihan populer karena performanya dengan Redis.

ruby
# app/jobs/process_order_job.rb
class ProcessOrderJob < ApplicationJob
  queue_as :default

  # Konfigurasi retry
  retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3
  retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 10
  discard_on ActiveJob::DeserializationError

  # Opsi Sidekiq (jika backend Sidekiq)
  sidekiq_options retry: 5, backtrace: true

  def perform(order_id)
    order = Order.find(order_id)

    OrderProcessor.new(order).call
  rescue ActiveRecord::RecordNotFound
    # Pesanan dihapus antara antrean dan eksekusi
    Rails.logger.warn("Order #{order_id} not found, skipping job")
  end
end
ruby
# app/jobs/batch_email_job.rb
class BatchEmailJob < ApplicationJob
  queue_as :mailers

  # Pembatasan laju dengan Sidekiq Enterprise atau gem throttle
  sidekiq_options throttle: { threshold: 100, period: 1.minute }

  def perform(user_ids, template_id)
    template = EmailTemplate.find(template_id)

    User.where(id: user_ids).find_each do |user|
      UserMailer.custom_email(user, template).deliver_later
    end
  end
end
ruby
# Mengantrekan pekerjaan
# Segera
ProcessOrderJob.perform_later(order.id)

# Tertunda
ProcessOrderJob.set(wait: 5.minutes).perform_later(order.id)

# Pada waktu spesifik
ProcessOrderJob.set(wait_until: Date.tomorrow.noon).perform_later(order.id)

# Antrean spesifik
ProcessOrderJob.set(queue: :critical).perform_later(order.id)

# Sinkron (untuk tes atau debugging)
ProcessOrderJob.perform_now(order.id)
ruby
# config/sidekiq.yml
:concurrency: 10
:queues:
  - [critical, 3]    # Prioritas tinggi, bobot 3
  - [default, 2]     # Prioritas sedang, bobot 2
  - [mailers, 1]     # Prioritas rendah, bobot 1
  - [low, 1]

:schedule:
  cleanup_job:
    cron: '0 3 * * *'  # Setiap hari pukul 3 pagi
    class: CleanupJob

Active Job mengabstraksi backend, tetapi mengakses fitur spesifik (batch, rate limiting) sering memerlukan keterikatan dengan backend yang dipilih.

Siap menguasai wawancara Ruby on Rails Anda?

Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.

Pengembangan API

Pertanyaan 13: Bagaimana membangun RESTful API dengan Rails?

Rails memudahkan pembangunan API JSON dengan controller API-only dan serializer. API yang baik memiliki versi, terdokumentasi, dan aman.

ruby
# app/controllers/api/v1/base_controller.rb
module Api
  module V1
    class BaseController < ActionController::API
      include ActionController::HttpAuthentication::Token::ControllerMethods

      before_action :authenticate_token!

      rescue_from ActiveRecord::RecordNotFound, with: :not_found
      rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
      rescue_from ActionController::ParameterMissing, with: :bad_request

      private

      def authenticate_token!
        authenticate_or_request_with_http_token do |token, options|
          @current_user = User.find_by(api_token: token)
        end
      end

      def current_user
        @current_user
      end

      def not_found(exception)
        render json: { error: 'Sumber daya tidak ditemukan', details: exception.message },
               status: :not_found
      end

      def unprocessable_entity(exception)
        render json: { error: 'Validasi gagal', details: exception.record.errors },
               status: :unprocessable_entity
      end

      def bad_request(exception)
        render json: { error: 'Permintaan tidak valid', details: exception.message },
               status: :bad_request
      end
    end
  end
end
ruby
# app/controllers/api/v1/products_controller.rb
module Api
  module V1
    class ProductsController < BaseController
      before_action :set_product, only: [:show, :update, :destroy]

      def index
        @products = Product.active
                           .includes(:category)
                           .page(params[:page])
                           .per(params[:per_page] || 20)

        render json: {
          data: ProductSerializer.new(@products).serializable_hash,
          meta: pagination_meta(@products)
        }
      end

      def show
        render json: ProductSerializer.new(@product, include: [:category, :reviews])
      end

      def create
        @product = Product.new(product_params)
        @product.save!

        render json: ProductSerializer.new(@product), status: :created
      end

      def update
        @product.update!(product_params)
        render json: ProductSerializer.new(@product)
      end

      def destroy
        @product.destroy!
        head :no_content
      end

      private

      def set_product
        @product = Product.find(params[:id])
      end

      def product_params
        params.require(:product).permit(:name, :description, :price, :category_id)
      end

      def pagination_meta(collection)
        {
          current_page: collection.current_page,
          total_pages: collection.total_pages,
          total_count: collection.total_count
        }
      end
    end
  end
end
ruby
# app/serializers/product_serializer.rb
# Dengan gem jsonapi-serializer
class ProductSerializer
  include JSONAPI::Serializer

  attributes :id, :name, :description, :price, :created_at

  attribute :formatted_price do |product|
    "$#{product.price.to_f.round(2)}"
  end

  belongs_to :category
  has_many :reviews

  link :self do |product|
    Rails.application.routes.url_helpers.api_v1_product_url(product)
  end
end

Praktik terbaik API: lakukan versi dengan namespace, gunakan kode HTTP yang sesuai, paginasi koleksi, dan berikan pesan kesalahan yang jelas.

Pertanyaan 14: Bagaimana mengimplementasikan autentikasi JWT di Rails?

JWT (JSON Web Tokens) adalah metode autentikasi stateless yang populer untuk API. Token mengkodekan identitas dan validitas pengguna.

ruby
# app/services/jwt_service.rb
class JwtService
  SECRET_KEY = Rails.application.credentials.secret_key_base
  ALGORITHM = 'HS256'.freeze

  class << self
    def encode(payload, exp = 24.hours.from_now)
      payload[:exp] = exp.to_i
      payload[:iat] = Time.current.to_i
      JWT.encode(payload, SECRET_KEY, ALGORITHM)
    end

    def decode(token)
      decoded = JWT.decode(token, SECRET_KEY, true, algorithm: ALGORITHM)
      HashWithIndifferentAccess.new(decoded.first)
    rescue JWT::ExpiredSignature
      raise AuthenticationError, 'Token kedaluwarsa'
    rescue JWT::DecodeError
      raise AuthenticationError, 'Token tidak valid'
    end
  end
end
ruby
# app/controllers/api/v1/auth_controller.rb
module Api
  module V1
    class AuthController < ActionController::API
      def login
        user = User.find_by(email: params[:email])

        if user&.authenticate(params[:password])
          token = JwtService.encode(user_id: user.id)
          render json: {
            token: token,
            user: UserSerializer.new(user),
            expires_at: 24.hours.from_now
          }
        else
          render json: { error: 'Kredensial tidak valid' }, status: :unauthorized
        end
      end

      def refresh
        token = JwtService.encode(user_id: current_user.id)
        render json: { token: token, expires_at: 24.hours.from_now }
      end
    end
  end
end
ruby
# app/controllers/concerns/jwt_authenticatable.rb
module JwtAuthenticatable
  extend ActiveSupport::Concern

  included do
    before_action :authenticate_jwt!
  end

  private

  def authenticate_jwt!
    header = request.headers['Authorization']
    token = header&.split(' ')&.last

    raise AuthenticationError, 'Token tidak ada' unless token

    decoded = JwtService.decode(token)
    @current_user = User.find(decoded[:user_id])
  rescue AuthenticationError => e
    render json: { error: e.message }, status: :unauthorized
  rescue ActiveRecord::RecordNotFound
    render json: { error: 'Pengguna tidak ditemukan' }, status: :unauthorized
  end

  def current_user
    @current_user
  end
end

Untuk produksi, pertimbangkan: refresh token, blacklist token saat logout, dan waktu kedaluwarsa singkat. Gem seperti devise-jwt mempermudah implementasi.

Caching dan performa

Pertanyaan 15: Bagaimana mengimplementasikan caching di Rails?

Rails menawarkan beberapa tingkat caching: fragment caching, Russian Doll caching, low-level caching. Pilihannya bergantung pada kasus penggunaan.

ruby
# config/environments/production.rb
config.action_controller.perform_caching = true
config.cache_store = :redis_cache_store, {
  url: ENV['REDIS_URL'],
  namespace: 'myapp:cache',
  expires_in: 1.day,
  race_condition_ttl: 10.seconds
}
erb
<%# app/views/products/index.html.erb %>
<%# Fragment caching dengan kunci cache otomatis %>
<% @products.each do |product| %>
  <%# Cache berdasarkan updated_at produk %>
  <% cache product do %>
    <%= render product %>
  <% end %>
<% end %>

<%# Russian Doll caching - cache bersarang %>
<% cache ['v1', @category] do %>
  <h2><%= @category.name %></h2>

  <% @category.products.each do |product| %>
    <% cache ['v1', product] do %>
      <%= render product %>
    <% end %>
  <% end %>
<% end %>

<%# Cache bersyarat %>
<% cache_if current_user.nil?, @product do %>
  <%= render @product %>
<% end %>
ruby
# app/models/product.rb
class Product < ApplicationRecord
  # Touch parent untuk membatalkan Russian Doll cache
  belongs_to :category, touch: true

  # Kunci cache kustom
  def cache_key_with_version
    "#{super}/#{reviews.maximum(:updated_at)&.to_i}"
  end
end
ruby
# Low-level caching di service
class DashboardStatsService
  def call
    Rails.cache.fetch('dashboard:stats', expires_in: 15.minutes) do
      {
        total_users: User.count,
        active_users: User.where('last_sign_in_at > ?', 30.days.ago).count,
        total_orders: Order.completed.count,
        revenue_mtd: Order.completed.where(created_at: Time.current.beginning_of_month..).sum(:total)
      }
    end
  end
end

# Cache dengan perlindungan terhadap race condition
Rails.cache.fetch('popular_products', expires_in: 1.hour, race_condition_ttl: 10.seconds) do
  Product.bestsellers.limit(10).to_a
end

# Pembatalan eksplisit
Rails.cache.delete('dashboard:stats')
Rails.cache.delete_matched('products:*')

Russian Doll caching efektif karena hanya fragmen yang dimodifikasi yang diregenerasi. Gunakan touch: true pada asosiasi untuk menyebarkan invalidasi.

Pertanyaan 16: Bagaimana mengoptimalkan performa aplikasi Rails?

Optimasi Rails meliputi banyak aspek: query DB, caching, aset, dan arsitektur. Pendekatan metodis dengan monitoring sangat penting.

ruby
# Optimasi basis data
# config/database.yml
production:
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  prepared_statements: true
  advisory_locks: true

# app/models/order.rb
class Order < ApplicationRecord
  # Indeks komposit untuk query yang sering
  # add_index :orders, [:user_id, :status, :created_at]

  # Pilih hanya kolom yang diperlukan
  scope :summary, -> { select(:id, :status, :total, :created_at) }

  # Pemrosesan batch untuk volume besar
  def self.process_pending
    pending.find_each(batch_size: 1000) do |order|
      ProcessOrderJob.perform_later(order.id)
    end
  end

  # Hindari perhitungan yang berulang
  def self.revenue_by_month
    completed
      .group("DATE_TRUNC('month', created_at)")
      .sum(:total)
  end
end
ruby
# Optimasi memori
# config/puma.rb
workers ENV.fetch("WEB_CONCURRENCY") { 2 }
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count

preload_app!

before_fork do
  ActiveRecord::Base.connection_pool.disconnect!
end

on_worker_boot do
  ActiveRecord::Base.establish_connection
end
ruby
# Profiling dengan rack-mini-profiler
# Gemfile
group :development do
  gem 'rack-mini-profiler'
  gem 'memory_profiler'
  gem 'stackprof'
end

# config/initializers/mini_profiler.rb
if defined?(Rack::MiniProfiler)
  Rack::MiniProfiler.config.position = 'bottom-right'
  Rack::MiniProfiler.config.start_hidden = true
end
ruby
# Lazy loading dan paginasi
class ProductsController < ApplicationController
  def index
    @products = Product.active
                       .includes(:category, :primary_image)
                       .page(params[:page])
                       .per(24)

    # Prefetch untuk halaman berikutnya
    if @products.next_page
      Rails.cache.fetch("products:page:#{@products.next_page}", expires_in: 5.minutes) do
        Product.active.page(@products.next_page).per(24).to_a
      end
    end
  end
end

Alat penting: rack-mini-profiler untuk profiling, bullet untuk deteksi N+1, New Relic atau Scout untuk monitoring produksi.

Keamanan

Pertanyaan 17: Apa praktik terbaik keamanan di Rails?

Rails menyertakan perlindungan default terhadap kerentanan umum. Memahami dan mengonfigurasi perlindungan ini dengan benar sangat penting.

ruby
# Perlindungan CSRF
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # Aktif secara default, melontarkan exception jika token tidak valid
  protect_from_forgery with: :exception

  # Untuk API gunakan :null_session
  # protect_from_forgery with: :null_session
end

# Pada view, token disertakan otomatis di formulir
# <%= form_with ... %> menyertakan authenticity_token

# Untuk permintaan AJAX
# Tambahkan header X-CSRF-Token dengan nilai csrf_meta_tags
ruby
# Pencegahan SQL Injection
# ✅ Parameter yang diinterpolasi otomatis di-escape
User.where('email = ?', params[:email])
User.where(email: params[:email])

# ❌ BAHAYA - Interpolasi langsung
User.where("email = '#{params[:email]}'")

# ✅ Untuk klausa ORDER dinamis
ALLOWED_SORTS = %w[name created_at price].freeze
sort_column = ALLOWED_SORTS.include?(params[:sort]) ? params[:sort] : 'name'
Product.order(sort_column)
ruby
# Perlindungan XSS
# Rails secara otomatis meng-escape HTML di view

# ✅ Otomatis di-escape
<%= user.name %>

# ❌ Berbahaya - konten tanpa escape
<%== user.bio %>
<%= raw user.bio %>
<%= user.bio.html_safe %>

# ✅ Untuk HTML aman, gunakan sanitize
<%= sanitize user.bio, tags: %w[p br strong em] %>
ruby
# Strong Parameters
class UsersController < ApplicationController
  def update
    @user.update!(user_params)
  end

  private

  def user_params
    # Whitelist eksplisit atribut yang diizinkan
    params.require(:user).permit(:name, :email, :avatar)

    # Hanya untuk admin
    if current_user.admin?
      params.require(:user).permit(:name, :email, :role, :active)
    else
      params.require(:user).permit(:name, :email)
    end
  end
end
ruby
# Header keamanan
# config/initializers/secure_headers.rb
Rails.application.config.action_dispatch.default_headers = {
  'X-Frame-Options' => 'SAMEORIGIN',
  'X-XSS-Protection' => '1; mode=block',
  'X-Content-Type-Options' => 'nosniff',
  'X-Download-Options' => 'noopen',
  'X-Permitted-Cross-Domain-Policies' => 'none',
  'Referrer-Policy' => 'strict-origin-when-cross-origin'
}

# Content Security Policy
Rails.application.config.content_security_policy do |policy|
  policy.default_src :self
  policy.script_src  :self
  policy.style_src   :self, :unsafe_inline
  policy.img_src     :self, :data, 'https:'
end

Audit secara berkala dengan brakeman (analisis keamanan statis) dan jaga gem tetap up-to-date dengan bundle audit.

Pertanyaan 18: Bagaimana menangani autentikasi dan otorisasi di Rails?

Autentikasi memverifikasi identitas, otorisasi mengontrol izin. Devise menangani autentikasi, Pundit atau CanCanCan menangani otorisasi.

ruby
# Setup Devise
# app/models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :confirmable, :lockable, :trackable

  enum role: { user: 0, moderator: 1, admin: 2 }

  def admin?
    role == 'admin'
  end
end
ruby
# Kebijakan Pundit
# app/policies/article_policy.rb
class ArticlePolicy < ApplicationPolicy
  def index?
    true
  end

  def show?
    record.published? || owner_or_admin?
  end

  def create?
    user.present?
  end

  def update?
    owner_or_admin?
  end

  def destroy?
    owner_or_admin?
  end

  def publish?
    user&.admin? || user&.moderator?
  end

  # Scope untuk koleksi
  class Scope < Scope
    def resolve
      if user&.admin?
        scope.all
      elsif user
        scope.where(published: true).or(scope.where(author: user))
      else
        scope.where(published: true)
      end
    end
  end

  private

  def owner_or_admin?
    user&.admin? || record.author == user
  end
end
ruby
# Controller dengan Pundit
class ArticlesController < ApplicationController
  include Pundit::Authorization

  after_action :verify_authorized, except: :index
  after_action :verify_policy_scoped, only: :index

  def index
    @articles = policy_scope(Article).includes(:author).page(params[:page])
  end

  def show
    @article = Article.find(params[:id])
    authorize @article
  end

  def update
    @article = Article.find(params[:id])
    authorize @article

    if @article.update(article_params)
      redirect_to @article, notice: 'Artikel diperbarui.'
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def publish
    @article = Article.find(params[:id])
    authorize @article

    @article.update!(published: true, published_at: Time.current)
    redirect_to @article, notice: 'Artikel dipublikasikan.'
  end

  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

  private

  def user_not_authorized
    flash[:alert] = "Anda tidak diizinkan melakukan tindakan ini."
    redirect_back(fallback_location: root_path)
  end
end

Pundit lebih eksplisit dan dapat diuji daripada CanCanCan. Setiap aksi memiliki metode policy yang sesuai, dan scope memfilter koleksi secara otomatis.

Rails tingkat lanjut

Pertanyaan 19: Jelaskan pola Repository di Rails

Pola Repository mengisolasi logika akses data dari sisa aplikasi. Meskipun Rails menggunakan Active Record (pola berbeda), Repository dapat berguna untuk kasus kompleks.

ruby
# app/repositories/base_repository.rb
class BaseRepository
  def initialize(model_class)
    @model_class = model_class
  end

  def all
    @model_class.all
  end

  def find(id)
    @model_class.find(id)
  end

  def find_by(attributes)
    @model_class.find_by(attributes)
  end

  def create(attributes)
    @model_class.create(attributes)
  end

  def update(record, attributes)
    record.update(attributes)
  end

  def delete(record)
    record.destroy
  end
end
ruby
# app/repositories/product_repository.rb
class ProductRepository < BaseRepository
  def initialize
    super(Product)
  end

  def active
    @model_class.where(active: true)
  end

  def in_category(category_id)
    @model_class.where(category_id: category_id)
  end

  def search(query)
    @model_class.where('name ILIKE ? OR description ILIKE ?',
                       "%#{query}%", "%#{query}%")
  end

  def with_stock
    @model_class.where('stock_quantity > 0')
  end

  def bestsellers(limit: 10)
    @model_class
      .joins(:order_items)
      .group(:id)
      .order('COUNT(order_items.id) DESC')
      .limit(limit)
  end

  def for_homepage
    active
      .with_stock
      .includes(:category, :primary_image)
      .order(featured: :desc, created_at: :desc)
      .limit(12)
  end
end
ruby
# Penggunaan dalam service
class ProductSearchService
  def initialize(repository: ProductRepository.new)
    @repository = repository
  end

  def call(params)
    products = @repository.active
    products = products.in_category(params[:category]) if params[:category]
    products = products.search(params[:query]) if params[:query].present?
    products = products.with_stock if params[:in_stock]
    products
  end
end

# Memudahkan pengujian dengan mock
RSpec.describe ProductSearchService do
  let(:repository) { instance_double(ProductRepository) }
  let(:service) { described_class.new(repository: repository) }

  it 'memfilter berdasarkan kategori' do
    products = double('products')
    allow(repository).to receive(:active).and_return(products)
    allow(products).to receive(:in_category).with(1).and_return(products)

    service.call(category: 1)

    expect(products).to have_received(:in_category).with(1)
  end
end

Repository opsional di Rails karena Active Record sudah merupakan pola yang sangat baik. Gunakan untuk query kompleks atau ketika isolasi penyimpanan penting.

Pertanyaan 20: Bagaimana mengimplementasikan pola CQRS di Rails?

CQRS (Command Query Responsibility Segregation) memisahkan operasi baca dan tulis. Di Rails, ini berarti kelas terpisah untuk query dan command.

ruby
# app/commands/base_command.rb
class BaseCommand
  include ActiveModel::Validations

  def self.call(*args)
    new(*args).call
  end

  def call
    return failure(errors) unless valid?

    execute
  end

  private

  def execute
    raise NotImplementedError
  end

  def success(data = nil)
    CommandResult.success(data)
  end

  def failure(errors)
    CommandResult.failure(errors)
  end
end

CommandResult = Struct.new(:success, :data, :errors, keyword_init: true) do
  def success? = success
  def failure? = !success

  def self.success(data)
    new(success: true, data: data, errors: [])
  end

  def self.failure(errors)
    new(success: false, data: nil, errors: Array(errors))
  end
end
ruby
# app/commands/orders/create_order_command.rb
module Orders
  class CreateOrderCommand < BaseCommand
    attr_reader :user, :items, :shipping_address

    validates :user, presence: true
    validates :items, presence: true
    validate :validate_items_availability

    def initialize(user:, items:, shipping_address:)
      @user = user
      @items = items
      @shipping_address = shipping_address
    end

    private

    def execute
      order = nil

      ActiveRecord::Base.transaction do
        order = Order.create!(
          user: user,
          shipping_address: shipping_address,
          status: 'pending'
        )

        items.each do |item|
          order.items.create!(
            product_id: item[:product_id],
            quantity: item[:quantity],
            unit_price: Product.find(item[:product_id]).price
          )
        end

        order.calculate_total!
      end

      OrderCreatedEvent.broadcast(order)
      success(order)
    rescue ActiveRecord::RecordInvalid => e
      failure(e.message)
    end

    def validate_items_availability
      items.each do |item|
        product = Product.find_by(id: item[:product_id])

        unless product&.stock_quantity&.>= item[:quantity]
          errors.add(:items, "Produk #{item[:product_id]} tidak tersedia")
        end
      end
    end
  end
end
ruby
# app/queries/orders/user_orders_query.rb
module Orders
  class UserOrdersQuery
    def initialize(user, params = {})
      @user = user
      @params = params
    end

    def call
      orders = @user.orders.includes(:items, items: :product)
      orders = apply_status_filter(orders)
      orders = apply_date_filter(orders)
      orders = apply_sorting(orders)
      orders.page(@params[:page]).per(@params[:per_page] || 20)
    end

    private

    def apply_status_filter(orders)
      return orders unless @params[:status]
      orders.where(status: @params[:status])
    end

    def apply_date_filter(orders)
      orders = orders.where('created_at >= ?', @params[:from]) if @params[:from]
      orders = orders.where('created_at <= ?', @params[:to]) if @params[:to]
      orders
    end

    def apply_sorting(orders)
      case @params[:sort]
      when 'oldest' then orders.order(created_at: :asc)
      when 'total_desc' then orders.order(total: :desc)
      else orders.order(created_at: :desc)
      end
    end
  end
end
ruby
# Controller menggunakan CQRS
class OrdersController < ApplicationController
  def index
    @orders = Orders::UserOrdersQuery.new(current_user, filter_params).call
  end

  def create
    result = Orders::CreateOrderCommand.call(
      user: current_user,
      items: order_params[:items],
      shipping_address: order_params[:shipping_address]
    )

    if result.success?
      redirect_to result.data, notice: 'Pesanan dibuat!'
    else
      flash.now[:alert] = result.errors.join(', ')
      render :new, status: :unprocessable_entity
    end
  end
end

CQRS bersinar pada aplikasi kompleks dengan kebutuhan baca/tulis yang asimetris. Untuk CRUD sederhana, ini adalah over-engineering.

Pertanyaan 21: Bagaimana menangani WebSocket dengan Action Cable?

Action Cable mengintegrasikan WebSocket ke dalam Rails untuk komunikasi dua arah secara real-time. Action Cable menggunakan Redis untuk sinkronisasi antar server.

ruby
# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    private

    def find_verified_user
      # Melalui cookie sesi
      if verified_user = User.find_by(id: cookies.encrypted[:user_id])
        verified_user
      # Melalui JWT untuk API
      elsif verified_user = verify_jwt_token
        verified_user
      else
        reject_unauthorized_connection
      end
    end

    def verify_jwt_token
      token = request.params[:token]
      return nil unless token

      decoded = JwtService.decode(token)
      User.find(decoded[:user_id])
    rescue
      nil
    end
  end
end
ruby
# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
  def subscribed
    @room = ChatRoom.find(params[:room_id])

    # Periksa izin
    unless @room.accessible_by?(current_user)
      reject
      return
    end

    stream_for @room

    # Beri tahu yang lain tentang kehadiran
    broadcast_presence(:joined)
  end

  def unsubscribed
    broadcast_presence(:left) if @room
  end

  def send_message(data)
    message = @room.messages.create!(
      user: current_user,
      content: data['content']
    )

    # Siarkan ke semua subscriber
    ChatChannel.broadcast_to(@room, {
      type: 'message',
      message: MessageSerializer.new(message).as_json
    })
  end

  def typing
    ChatChannel.broadcast_to(@room, {
      type: 'typing',
      user: current_user.name
    })
  end

  private

  def broadcast_presence(action)
    ChatChannel.broadcast_to(@room, {
      type: 'presence',
      action: action,
      user: current_user.name,
      online_count: @room.online_users_count
    })
  end
end
app/javascript/channels/chat_channel.jsjavascript
import consumer from "./consumer"

const chatChannel = consumer.subscriptions.create(
  { channel: "ChatChannel", room_id: roomId },
  {
    connected() {
      console.log("Connected to chat")
    },

    disconnected() {
      console.log("Disconnected from chat")
    },

    received(data) {
      switch(data.type) {
        case 'message':
          this.appendMessage(data.message)
          break
        case 'typing':
          this.showTypingIndicator(data.user)
          break
        case 'presence':
          this.updatePresence(data)
          break
      }
    },

    sendMessage(content) {
      this.perform('send_message', { content: content })
    },

    notifyTyping() {
      this.perform('typing')
    }
  }
)

Action Cable secara otomatis menangani koneksi ulang dan sinkronisasi. Di produksi, konfigurasikan Redis sebagai adapter dan skalakan sesuai dengan koneksi bersamaan.

Pertanyaan 22: Bagaimana mengimplementasikan multi-tenancy di Rails?

Multi-tenancy memungkinkan aplikasi melayani beberapa pelanggan (tenant) yang terisolasi. Tiga pendekatan utama: tingkat database, skema, atau baris.

ruby
# Multitenancy tingkat baris dengan ActsAsTenant atau manual
# app/models/concerns/tenant_scoped.rb
module TenantScoped
  extend ActiveSupport::Concern

  included do
    belongs_to :tenant

    # Scope default ke tenant saat ini
    default_scope -> { where(tenant: Current.tenant) if Current.tenant }

    # Validasi tenant
    before_validation :set_tenant, on: :create
  end

  private

  def set_tenant
    self.tenant ||= Current.tenant
  end
end

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :tenant, :user
end
ruby
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :set_current_tenant

  private

  def set_current_tenant
    Current.tenant = resolve_tenant
    Current.user = current_user
  end

  def resolve_tenant
    # Melalui subdomain
    if request.subdomain.present? && request.subdomain != 'www'
      Tenant.find_by!(subdomain: request.subdomain)
    # Melalui header (untuk API)
    elsif request.headers['X-Tenant-ID'].present?
      Tenant.find(request.headers['X-Tenant-ID'])
    # Melalui pengguna
    elsif current_user
      current_user.tenant
    end
  rescue ActiveRecord::RecordNotFound
    redirect_to root_url(subdomain: 'www'), alert: 'Tenant tidak ditemukan'
  end
end
ruby
# app/models/project.rb
class Project < ApplicationRecord
  include TenantScoped

  has_many :tasks
  belongs_to :owner, class_name: 'User'
end

# app/models/user.rb
class User < ApplicationRecord
  include TenantScoped

  has_many :projects, foreign_key: :owner_id

  # Admin dapat menjadi anggota beberapa tenant
  has_many :tenant_memberships
  has_many :accessible_tenants, through: :tenant_memberships, source: :tenant
end
ruby
# Tingkat skema dengan gem Apartment (PostgreSQL)
# config/initializers/apartment.rb
Apartment.configure do |config|
  config.excluded_models = %w[Tenant User]
  config.tenant_names = -> { Tenant.pluck(:subdomain) }
end

# Penggunaan
Apartment::Tenant.switch('acme') do
  # Semua query dalam blok ini menggunakan skema 'acme'
  Project.all # SELECT * FROM acme.projects
end

Tingkat baris paling sederhana tetapi membutuhkan perhatian terus-menerus terhadap kebocoran. Tingkat skema memberikan isolasi lebih baik tetapi memperumit migrasi. Pilih sesuai kebutuhan keamanan dan skalabilitas.

Pertanyaan 23: Bagaimana menyiapkan arsitektur microservices dengan Rails?

Rails dapat menjadi dasar arsitektur microservices dengan komunikasi melalui HTTP/gRPC atau antrean pesan. Kuncinya adalah mendefinisikan batas dengan baik.

ruby
# Klien layanan HTTP
# app/services/payment_service_client.rb
class PaymentServiceClient
  include HTTParty

  base_uri ENV.fetch('PAYMENT_SERVICE_URL')

  def initialize
    @options = {
      headers: {
        'Content-Type' => 'application/json',
        'X-Service-Token' => ENV.fetch('SERVICE_TOKEN')
      },
      timeout: 10
    }
  end

  def create_charge(amount:, currency:, source:, metadata: {})
    response = self.class.post('/charges', @options.merge(
      body: { amount: amount, currency: currency, source: source, metadata: metadata }.to_json
    ))

    handle_response(response)
  end

  def get_charge(charge_id)
    response = self.class.get("/charges/#{charge_id}", @options)
    handle_response(response)
  end

  private

  def handle_response(response)
    case response.code
    when 200..299
      ServiceResult.success(response.parsed_response)
    when 400..499
      ServiceResult.failure(response.parsed_response['error'], code: response.code)
    else
      ServiceResult.failure('Layanan tidak tersedia', code: response.code)
    end
  rescue Net::OpenTimeout, Net::ReadTimeout
    ServiceResult.failure('Timeout layanan')
  end
end
ruby
# Komunikasi event-driven dengan Sidekiq/Redis
# app/events/order_events.rb
module OrderEvents
  class Created
    include Wisper::Publisher

    def call(order)
      broadcast(:order_created, order)
    end
  end
end

# app/listeners/inventory_listener.rb
class InventoryListener
  def order_created(order)
    order.items.each do |item|
      InventoryServiceClient.new.reserve_stock(
        product_id: item.product_id,
        quantity: item.quantity,
        reference: order.id
      )
    end
  end
end

# config/initializers/wisper.rb
Wisper.subscribe(InventoryListener.new, async: true)
Wisper.subscribe(NotificationListener.new, async: true)
ruby
# Pola API Gateway
# app/controllers/api/v1/gateway_controller.rb
module Api
  module V1
    class GatewayController < BaseController
      # Agregasikan beberapa layanan
      def dashboard
        results = Parallel.map([:orders, :inventory, :analytics], in_threads: 3) do |service|
          fetch_from_service(service)
        end

        render json: {
          orders: results[0],
          inventory: results[1],
          analytics: results[2]
        }
      end

      private

      def fetch_from_service(service)
        case service
        when :orders
          OrderServiceClient.new.recent_orders(limit: 5)
        when :inventory
          InventoryServiceClient.new.low_stock_alerts
        when :analytics
          AnalyticsServiceClient.new.daily_summary
        end
      rescue => e
        { error: "#{service} tidak tersedia", message: e.message }
      end
    end
  end
end

Untuk microservices Rails: definisikan kontrak API yang jelas (OpenAPI), terapkan circuit breaker (gem circuitbox), dan gunakan tracing terdistribusi (gem opentelemetry).

Pertanyaan 24: Bagaimana men-deploy aplikasi Rails ke produksi?

Deployment Rails modern menggunakan kontainer atau PaaS. Konfigurasi produksi yang andal mencakup aset, basis data, dan monitoring.

ruby
# config/environments/production.rb
Rails.application.configure do
  config.cache_classes = true
  config.eager_load = true
  config.consider_all_requests_local = false

  # Aset
  config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
  config.assets.compile = false
  config.assets.digest = true

  # Logging
  config.log_level = ENV.fetch('LOG_LEVEL', 'info').to_sym
  config.log_tags = [:request_id]
  config.logger = ActiveSupport::Logger.new(STDOUT)
    .tap  { |logger| logger.formatter = Logger::Formatter.new }
    .then { |logger| ActiveSupport::TaggedLogging.new(logger) }

  # Cache
  config.cache_store = :redis_cache_store, {
    url: ENV['REDIS_URL'],
    expires_in: 1.day
  }

  # Paksa SSL
  config.force_ssl = true
  config.ssl_options = { hsts: { subdomains: true } }

  # Action Mailer
  config.action_mailer.delivery_method = :smtp
  config.action_mailer.smtp_settings = {
    address: ENV['SMTP_HOST'],
    port: ENV['SMTP_PORT'],
    user_name: ENV['SMTP_USER'],
    password: ENV['SMTP_PASSWORD'],
    authentication: :plain,
    enable_starttls_auto: true
  }
end
dockerfile
# Dockerfile
FROM ruby:3.3-alpine AS builder

RUN apk add --no-cache build-base postgresql-dev nodejs yarn

WORKDIR /app

COPY Gemfile Gemfile.lock ./
RUN bundle config set --local deployment true && \
    bundle config set --local without 'development test' && \
    bundle install

COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

COPY . .
RUN bundle exec rails assets:precompile

# Image produksi
FROM ruby:3.3-alpine

RUN apk add --no-cache postgresql-client tzdata

WORKDIR /app

COPY --from=builder /app /app
COPY --from=builder /usr/local/bundle /usr/local/bundle

ENV RAILS_ENV=production
ENV RAILS_LOG_TO_STDOUT=true

EXPOSE 3000

CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
yaml
# docker-compose.production.yml
version: '3.8'

services:
  web:
    build: .
    environment:
      - DATABASE_URL=postgres://user:pass@db/app_production
      - REDIS_URL=redis://redis:6379/0
      - SECRET_KEY_BASE=${SECRET_KEY_BASE}
    depends_on:
      - db
      - redis
    deploy:
      replicas: 3
      resources:
        limits:
          memory: 512M

  sidekiq:
    build: .
    command: bundle exec sidekiq
    environment:
      - DATABASE_URL=postgres://user:pass@db/app_production
      - REDIS_URL=redis://redis:6379/0
    depends_on:
      - db
      - redis
    deploy:
      replicas: 2

  db:
    image: postgres:15-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_PASSWORD=${DB_PASSWORD}

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data

volumes:
  postgres_data:
  redis_data:

Daftar periksa produksi: SSL wajib, secret melalui ENV, health check, backup DB otomatis, monitoring (APM + log + metrik), dan alerting yang dikonfigurasi.

Pertanyaan 25: Apa fitur baru di Rails 7+ yang perlu diketahui?

Rails 7+ membawa perubahan signifikan: Hotwire secara default, import maps, kredensial terenkripsi yang ditingkatkan, dan banyak optimasi.

ruby
# Hotwire - Turbo Frames
# app/views/articles/index.html.erb
<%= turbo_frame_tag "articles" do %>
  <% @articles.each do |article| %>
    <%= turbo_frame_tag dom_id(article) do %>
      <%= render article %>
    <% end %>
  <% end %>

  <%= link_to "Muat lebih banyak", articles_path(page: @page + 1),
              data: { turbo_frame: "articles" } %>
<% end %>

# Turbo Streams untuk pembaruan real-time
# app/controllers/comments_controller.rb
def create
  @comment = @article.comments.create!(comment_params.merge(user: current_user))

  respond_to do |format|
    format.turbo_stream
    format.html { redirect_to @article }
  end
end

# app/views/comments/create.turbo_stream.erb
<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comments_count", @article.comments.count %>
<%= turbo_stream.replace "comment_form", partial: "comments/form", locals: { comment: Comment.new } %>
ruby
# Controller Stimulus
# app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"
import { debounce } from "lodash-es"

export default class extends Controller {
  static targets = ["input", "results"]
  static values = { url: String }

  connect() {
    this.search = debounce(this.search.bind(this), 300)
  }

  async search() {
    const query = this.inputTarget.value
    if (query.length < 2) return

    const response = await fetch(`${this.urlValue}?q=${encodeURIComponent(query)}`)
    this.resultsTarget.innerHTML = await response.text()
  }
}
ruby
# Import Maps (tanpa bundler JavaScript)
# config/importmap.rb
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"

pin_all_from "app/javascript/controllers", under: "controllers"

# Pin dari CDN
pin "lodash-es", to: "https://ga.jspm.io/npm:lodash-es@4.17.21/lodash.js"
ruby
# Active Record Encryption (Rails 7+)
# app/models/user.rb
class User < ApplicationRecord
  encrypts :email, deterministic: true  # Memungkinkan pencarian
  encrypts :phone_number                 # Non-deterministik secara default
  encrypts :ssn, deterministic: true, downcase: true
end

# config/credentials.yml.enc
active_record_encryption:
  primary_key: abc123...
  deterministic_key: def456...
  key_derivation_salt: ghi789...
ruby
# Peningkatan antarmuka query
# Rails 7.1+

# Query asinkron
users = User.where(active: true).load_async
# Lanjutkan pemrosesan saat query berjalan
# Akses hasil dengan users.to_a

# Common Table Expressions (CTE)
User.with(
  recent_orders: Order.where('created_at > ?', 30.days.ago)
).joins('JOIN recent_orders ON recent_orders.user_id = users.id')

# Deteksi inverse_of otomatis
class Author < ApplicationRecord
  has_many :books # inverse_of terdeteksi otomatis
end

# Strict loading secara default (mencegah N+1)
class ApplicationRecord < ActiveRecord::Base
  self.strict_loading_by_default = true
end

Rails 7+ mengutamakan kesederhanaan (tanpa Webpack secara default) dan HTML-over-the-wire dengan Hotwire. Pendekatan ini mengurangi kompleksitas JavaScript sambil menghadirkan pengalaman pengguna yang modern.

Kesimpulan

Wawancara Ruby on Rails menilai penguasaan seluruh framework dan pemahaman akan konvensinya. Poin penting yang perlu diingat:

Dasar-dasar: MVC, Active Record, migrasi, validasi, dan asosiasi

Arsitektur: Service Object, Concerns, Query Object, dan pola CQRS

Performa: query N+1, caching (fragment, Russian Doll, low-level), eager loading

Pengujian: RSpec, FactoryBot, request spec, dan praktik terbaik pengujian

Keamanan: CSRF, SQL injection, XSS, Strong Parameters, dan autentikasi/otorisasi

API: desain RESTful, JWT, serializer, dan pemberian versi

Produksi: background job, WebSocket, deployment, dan monitoring

Filosofi Rails (Convention over Configuration, DRY, dan Rails Way) memandu semua keputusan arsitektur. Menguasai prinsip-prinsip ini dan mengetahui kapan harus menyimpang darinya menunjukkan keahlian yang solid.

Mulai berlatih!

Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.

Tag

#ruby on rails
#ruby
#wawancara
#active record
#wawancara teknis

Bagikan

Artikel terkait