Tantangan Arsitektur: Dilema "Fat Model" Klasik di Rails
Pada Bagian 1 dari seri ini, kita telah membahas proses migrasi sistem ujian sekolah monolitik PHP ke Ruby 3.3, Ruby on Rails 8.1 API, React 19, dan PostgreSQL 16 (repositori open-source: mfarim/ruby-rails-elearning-react). Rails memberikan kecepatan pengembangan luar biasa: migrasi basis data yang ekspresif, Puma multithreading berkinerja tinggi, dan kemudahan membangun REST API.
Namun, seiring bertambahnya kompleksitas fitur dalam platform—meliputi Computer Based Testing (CBT), bank soal, pengacakan opsi dan urutan pertanyaan, penilaian otomatis esai, serta pengawasan langsung berbasis WebSocket—muncul dilema klasik arsitektur Rails:
"Di mana logika orkestrasi bisnis seharusnya diletakkan ketika dogma lama 'Skinny Controller, Fat Model' mulai runtuh karena beban kompleksitas aplikasi enterprise?"
Mengapa Pendekatan Bawaan Rails Kurang Skalabel di Domain Kompleks
- God Model yang Mengumpulkan Ratusan Concerns:
Tradisi Rails mengajarkan pengembang untuk menaruh sebagian besar logika ke dalam model Active Record. Tak butuh waktu lama, model seperti
User.rbatauExamination.rbmembengkak dengan puluhan callback (before_save,after_commit), scopes, validasi, dan perhitungan bisnis custom. Akibatnya, pengujian unit sederhana harus memuat instansiasi basis data lengkap dengan cascading associations yang lambat. - Keterikatan Logika Bisnis dengan Layer Database: Di Rails tradisional, Active Record menggabungkan persistensi data, validasi skema, query database, dan logika domain dalam satu objek. Ketika suatu alur kerja butuh mengorkestrasi lima model berbeda (misalnya memvalidasi izin siswa, membuat sesi ujian, mengacak urutan soal, dan menghitung sisa waktu pengerjaan), menaruhnya di satu model jelas melanggar Single Responsibility Principle (SRP).
- Controller Sarat Query Database Langsung: Bila pengembang enggan membuat model terlalu besar tanpa rancangan arsitektur yang formal, controller kerap menjadi tempat pelarian: penuh dengan query Active Record, transaksi database mentah, dan penanganan error ad-hoc.
- "Service Objects" Liar Tanpa Kontrak Standar:
Banyak tim Rails membuat folder
app/services/. Namun tanpa aturan batas arsitektur (architectural boundaries), service tersebut kerap berubah menjadi skrip prosedural yang tetap membocorkan query database dan tidak memiliki format kembalian yang konsisten.
Untuk memecahkan masalah ini secara terstruktur, saya merancang dan menggabungkan Pull Request #1: refactor/clean-architecture-implementation. Pada artikel ini, kita membedah implementasi Clean Architecture rancangan Uncle Bob ke dalam Rails 8 dan React.
Clean Architecture di Rails: Struktur Layer & Boundary Rules
Prinsip utama Clean Architecture menyatakan: ketergantungan kode sumber hanya boleh mengarah ke dalam (inward), menuju aturan bisnis tingkat tinggi.
Dalam implementasi kami di Rails, lapisan ini terorganisir rapi di struktur direktori:
- Domain Entities & Models (
app/models/): Merepresentasikan struktur data inti dan integritas relasional relasi database. - Repositories (
app/repositories/): Mengenkapsulasi operasi persistensi dan query database agar terisolasi dari logika bisnis. - Use Cases / Interactors (
app/use_cases/): Menjalankan alur kerja bisnis spesifik (single responsibility). Layer ini murni tidak mengetahui perihal HTTP request, session, ataupun serialisasi JSON. - Interface Adapters (Controllers & Presenters): Menjembatani parameter HTTP dan Use Case, serta memformat output JSON.
- Result Contract (
UseCaseResult): Objek fungsional monad yang mengembalikan status:successatau:failure, menggantikan kontrol alur berbasis exception.
1. Result Monad: Kontrak Hasil Berbasis Railway-Oriented Programming
Pada banyak aplikasi Rails, metode bisnis sering melempar exception atau hanya mengembalikan nilai boolean, memaksa controller melakukan blok begin...rescue yang berantakan. Kami mengimplementasikan UseCaseResult (backend/app/use_cases/use_case_result.rb):
RUBY
class UseCaseResult
attr_reader :data, :error, :errors, :status
def initialize(success:, data: nil, error: nil, errors: nil, status: :ok)
@success = success
@data = data
@error = error
@errors = errors
@status = status
end
def success?
@success
end
def failure?
!@success
end
def self.success(data = nil, status: :ok)
new(success: true, data: data, status: status)
end
def self.failure(error, status: :bad_request, errors: nil)
new(success: false, error: error, errors: errors, status: status)
end
end
Manfaat Penerapan Ini:
- Setiap alur Use Case selalu mengembalikan kontrak yang pasti: sukses atau gagal dengan payload yang terstruktur.
- Status kode HTTP (
:ok,:created,:unauthorized,:forbidden,:not_found,:unprocessable_entity) didefinisikan langsung pada batas validasi bisnis, bukan ditebak oleh controller.
2. Repository Pattern: Mengisolasi Query Active Record
Repository memisahkan pemanggilan query database Active Record sehingga Use Case berinteraksi dengan abstraksi koleksi data domain, bukan pemanggilan langsung SQL atau chaining query Active Record yang rentan bocor.
Base Repository (backend/app/repositories/base_repository.rb):
RUBY
class BaseRepository
attr_reader :model_class
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(conditions)
model_class.find_by(conditions)
end
def create!(attributes)
model_class.create!(attributes)
end
def update!(id, attributes)
record = find(id)
record.update!(attributes)
record
end
def delete!(id)
record = find(id)
record.destroy!
end
def count
model_class.count
end
end
Repositori Khusus Sesuai Domain:
Kami membuat repositori spesifik untuk setiap modul bisnis:
UserRepository: Menyediakanfind_by_email(email)danfind_admin.ExaminationRepository: Mengenkapsulasi pencarian ujian, filter status aktif, dan asosiasi soal.ExamAttemptRepository: Menyediakanfind_or_initialize_for(exam, student)untuk mengelola status pengerjaan ujian siswa.AssignmentRepository&ClassroomRepository: Mengelola penugasan materi dan kelas rombel belajar.
RUBY
class UserRepository < BaseRepository
def initialize
super(User)
end
def find_by_email(email)
model_class.find_by(email: email)
end
def find_admin
model_class.find_by(role: 'ROLE_ADMIN')
end
end
3. Use Cases: Enkapsulasi Logika Bisnis Murni
Setiap Use Case hanya memiliki satu tanggung jawab dengan mengekspos metode .call. Mari kita telaah dua alur penting: Autentikasi dan Mesin Pelaksanaan Ujian CBT.
Use Case Autentikasi (backend/app/use_cases/auth/authenticate_user.rb):
RUBY
module Auth
class AuthenticateUser
def initialize(user_repo: UserRepository.new)
@user_repo = user_repo
end
def call(email:, password:)
user = @user_repo.find_by_email(email&.strip&.downcase)
return UseCaseResult.failure("Email atau password tidak valid", status: :unauthorized) unless user
return UseCaseResult.failure("Akun Anda sedang dinonaktifkan", status: :forbidden) unless user.active
return UseCaseResult.failure("Email atau password tidak valid", status: :unauthorized) unless user.authenticate(password)
token = JsonWebToken.encode(user_id: user.id)
UseCaseResult.success({
accessToken: token,
refreshToken: token,
tokenType: "Bearer",
userId: user.id,
name: user.name,
email: user.email,
roles: [user.role],
active: user.active,
impersonatedBy: user.impersonated_by_id,
token: token
})
end
end
end
Inisialisasi Ujian Siswa (backend/app/use_cases/exams/start_exam_attempt.rb):
Memulai ujian online memiliki berbagai skenario batas: memverifikasi izin siswa, mengecek hak remedi/ujian ulang, menghitung sisa waktu pengerjaan, mengacak urutan soal dan pilihan ganda sesuai kebijakan ujian, serta memulihkan jawaban yang sudah tersimpan jika browser siswa sempat tertutup.
RUBY
module Exams
class StartExamAttempt
def initialize(
exam_repo: ExaminationRepository.new,
attempt_repo: ExamAttemptRepository.new
)
@exam_repo = exam_repo
@attempt_repo = attempt_repo
end
def call(exam_id:, student:)
return UseCaseResult.failure("Data siswa tidak ditemukan", status: :forbidden) unless student
exam = @exam_repo.find(exam_id)
attempt = @attempt_repo.find_or_initialize_for(exam, student)
# Tangani attempt baru atau izin pengulangan ujian
if attempt.new_record? || (exam.allow_retry && attempt.status == 'completed')
attempt.status = 'in_progress'
attempt.started_at = Time.current
attempt.remaining_seconds = exam.duration_minutes * 60
attempt.violations_count = 0
attempt.save!
end
# Logika pengacakan soal terisolasi dari Controller
questions = exam.questions.order(:id).to_a
questions = questions.shuffle if exam.shuffle_questions
existing_answers = ExamAnswer.where(exam_attempt: attempt).index_by(&:question_id)
questions_payload = questions.map do |q|
options = q.options || []
options = options.shuffle if exam.shuffle_options
{
id: q.id,
examinationId: q.examination_id,
questionText: q.question_text,
questionType: q.question_type,
options: options,
points: q.points,
difficulty: q.difficulty,
studentAnswer: existing_answers[q.id]&.student_answer,
imagePath: q.image_path,
audioPath: q.audio_path
}
end
# Perhitungan sisa waktu berdasarkan waktu mulai di server
elapsed = (Time.current - attempt.started_at).to_i
remaining = [exam.duration_minutes * 60 - elapsed, 0].max
response_data = {
attemptId: attempt.id,
examId: exam.id,
examTitle: exam.title,
durationMinutes: exam.duration_minutes,
remainingSeconds: remaining,
startedAt: attempt.started_at.iso8601,
totalQuestions: questions.size,
questions: questions_payload
}
UseCaseResult.success(response_data)
rescue ActiveRecord::RecordNotFound => e
UseCaseResult.failure(e.message, status: :not_found)
end
end
end
Penilaian Otomatis & Pengumpulan Ujian (backend/app/use_cases/exams/submit_exam_attempt.rb):
Ketika siswa menyelesaikan ujian, use case ini langsung menilai jawaban pilihan ganda, menjumlahkan perolehan poin, menghitung kelulusan terhadap nilai KKM (passing_score), serta menandai status needs_grading bila terdapat soal esai yang memerlukan penilaian manual guru:
RUBY
module Exams
class SubmitExamAttempt
def initialize(attempt_repo: ExamAttemptRepository.new)
@attempt_repo = attempt_repo
end
def call(attempt_id:)
attempt = @attempt_repo.find(attempt_id)
exam = attempt.examination
return UseCaseResult.failure("Sesi ujian ini sudah selesai dikumpulkan") if attempt.status == 'completed'
answers = attempt.exam_answers.includes(:question)
total_points = exam.questions.sum(:points)
total_earned = 0.0
has_ungraded_essay = false
answers.each do |ans|
q = ans.question
next unless q
if q.question_type == 'multiple_choice'
is_correct = ans.student_answer.to_s.strip.upcase == q.correct_answer.to_s.strip.upcase
points = is_correct ? q.points : 0.0
ans.update!(is_correct: is_correct, points_awarded: points, graded_at: Time.current)
total_earned += points
elsif q.question_type == 'essay'
if ans.points_awarded.present?
total_earned += ans.points_awarded
else
has_ungraded_essay = true
end
end
end
score_pct = total_points > 0 ? (total_earned / total_points * 100.0).round(2) : 0.0
passed = score_pct >= exam.passing_score
final_status = has_ungraded_essay ? 'needs_grading' : 'completed'
attempt.update!(
score: score_pct,
passed: passed,
status: final_status,
finished_at: Time.current
)
UseCaseResult.success({
score: score_pct,
passed: passed,
status: final_status,
totalQuestions: exam.questions.count,
answeredCount: answers.count
})
end
end
end
4. Slim Controller & Serialisasi Terpusat
Dalam arsitektur baru ini, controller hanya memiliki tiga kewajiban:
- Memvalidasi autentikasi HTTP request.
- Meneruskan parameter ke Use Case yang dituju.
- Memanggil
handle_result(result)untuk merender JSON.
Base Controller (backend/app/controllers/api/v1/base_controller.rb):
RUBY
def handle_result(result, message: "Success")
if result.success?
json_response(data: result.data, message: message, status: result.status)
else
render_error(result.error, status: result.status, errors: result.errors)
end
end
Controller Pelaksanaan Ujian yang Ramping (backend/app/controllers/api/v1/exam_runner_controller.rb):
Sebelum PR #1, controller ini memiliki lebih dari 260 baris kode SQL mentah dan loop berlapis. Kini kodenya sangat ringkas:
RUBY
module Api
module V1
class ExamRunnerController < BaseController
def start
require_student!
return if performed?
result = start_exam_attempt_use_case.call(
exam_id: params[:id],
student: current_user.student
)
handle_result(result)
end
def answer
require_student!
return if performed?
result = save_exam_answer_use_case.call(
attempt_id: params[:attempt_id],
question_id: params[:questionId],
answer: params[:answer]
)
handle_result(result, message: "Jawaban tersimpan")
end
def submit
require_student!
return if performed?
result = submit_exam_attempt_use_case.call(attempt_id: params[:attempt_id])
handle_result(result, message: "Ujian berhasil dikumpulkan")
end
private
def start_exam_attempt_use_case
@start_exam_attempt_use_case ||= Exams::StartExamAttempt.new
end
def save_exam_answer_use_case
@save_exam_answer_use_case ||= Exams::SaveExamAnswer.new
end
def submit_exam_attempt_use_case
@submit_exam_attempt_use_case ||= Exams::SubmitExamAttempt.new
end
end
end
end
5. Clean Architecture di Frontend: Lapisan Typed Service
Clean Architecture tidak hanya relevan untuk sisi backend. Di frontend, jika komponen React memanggil langsung string endpoint seperti api.get('/exams/' + id) atau api.post(...), komponen tampilan menjadi terikat erat dengan format URL dan response envelope backend.
Pada PR #1, kami menyusun Typed Service Layer (frontend/src/services/) yang mencakup 10 modul domain:
Contoh: Service Ujian CBT Berbasis Tipe (frontend/src/services/examService.ts):
TYPESCRIPT
import api from '../api/client';
import type { ApiResponse, Exam, ExamMonitorItem, ExamStartResponse, Question } from '../types';
export const examService = {
async getAll(): Promise<Exam[]> {
const res = await api.get<ApiResponse<Exam[]>>('/exams');
return res.data.data;
},
async getById(id: number): Promise<Exam> {
const res = await api.get<ApiResponse<Exam>>(`/exams/${id}`);
return res.data.data;
},
// CBT Exam Runner
async startExam(examId: number): Promise<ExamStartResponse> {
const res = await api.post<ApiResponse<ExamStartResponse>>(`/exams/${examId}/start`);
return res.data.data;
},
async saveAnswer(attemptId: number, questionId: number, answer: string): Promise<void> {
await api.post(`/exams/attempts/${attemptId}/answer`, { questionId, answer });
},
async recordViolation(attemptId: number): Promise<{ violations: number }> {
const res = await api.post<ApiResponse<{ violations: number }>>(`/exams/attempts/${attemptId}/violation`);
return res.data.data;
},
async submitExam(attemptId: number): Promise<{
score: number;
passed: boolean;
status: string;
totalQuestions: number;
answeredCount: number;
}> {
const res = await api.post<ApiResponse<{
score: number;
passed: boolean;
status: string;
totalQuestions: number;
answeredCount: number;
}>>(`/exams/attempts/${attemptId}/submit`);
return res.data.data;
},
async getMonitorData(examId: number): Promise<ExamMonitorItem[]> {
const res = await api.get<ApiResponse<ExamMonitorItem[]>>(`/exams/${examId}/monitor`);
return res.data.data;
},
};
Perbandingan Komponen Sebelum vs. Sesudah:
Di ExamRunner.tsx dan ExamMonitor.tsx, komponen tidak lagi menyusun pemanggilan Axios langsung:
TYPESCRIPT
// SEBELUM: Pemanggilan Axios mentah tersebar di dalam useEffect
const res = await api.post(`/exams/${examId}/start`);
setExamData(res.data.data);
// SESUDAH: Pemanggilan service terstruktur dengan jaminan tipe data TypeScript
const examData = await examService.startExam(Number(examId));
setExamData(examData);
6. Isolasi Suite Pengujian Menggunakan RSpec & DatabaseCleaner
Salah satu keunggulan terbesar Clean Architecture adalah pengujian unit yang sangat cepat dan terisolasi.
Karena Use Case berinteraksi melalui abstraksi repositori dan menghasilkan objek UseCaseResult, kita dapat menguji logika bisnis secara mandiri tanpa harus memuat seluruh tumpukan HTTP stack Rails.
Pengujian Alur Autentikasi (backend/spec/use_cases/auth/authenticate_user_spec.rb):
RUBY
require 'rails_helper'
RSpec.describe Auth::AuthenticateUser do
let(:use_case) { described_class.new }
let!(:user) { User.create!(name: 'Guru Penguji', email: '[email protected]', password: 'password123', role: 'ROLE_TEACHER') }
describe '#call' do
it 'mengembalikan status sukses dengan token saat kredensial valid' do
result = use_case.call(email: '[email protected]', password: 'password123')
expect(result).to be_success
expect(result.data[:accessToken]).to be_present
expect(result.data[:userId]).to eq(user.id)
expect(result.data[:roles]).to include('ROLE_TEACHER')
end
it 'mengembalikan kegagalan unauthorized saat password salah' do
result = use_case.call(email: '[email protected]', password: 'passwordsalah')
expect(result).to be_failure
expect(result.status).to eq(:unauthorized)
expect(result.error).to eq("Invalid email or password")
end
end
end
Mencegah Kebocoran State dengan DatabaseCleaner (backend/spec/rails_helper.rb):
Untuk memastikan tidak ada data residu pengujian antar-spec, kami mengonfigurasi strategi DatabaseCleaner truncation:
RUBY
RSpec.configure do |config|
config.use_transactional_fixtures = true
config.infer_spec_type_from_file_location!
config.filter_rails_from_backtrace!
config.before(:suite) do
DatabaseCleaner.clean_with(:truncation)
end
end
Ringkasan Perubahan Arsitektur & Pelajaran Teknis
| Dimensi Arsitektur | Sebelum PR #1 (Rails API Monolitik) | Sesudah PR #1 (Clean Architecture) |
|---|---|---|
| Lokasi Logika Bisnis | Tersebar di dalam controller dan Active Record model | Terisolasi di dalam kelas Use Case bereputasi tunggal |
| Query Database | Query mentah ActiveRecord tersebar di berbagai controller | Dienkapsulasi di dalam kelas Repository Pattern |
| Kontrak Kembalian | Bervariasi (boolean, exception, atau hash JSON kustom) | Terstandarisasi via UseCaseResult Monad (success? / failure?) |
| API Controller | Controller gemuk (200+ baris) dengan penyelamatan exception | Controller sangat ramping (< 40 baris) melalui handle_result |
| Komunikasi Frontend | Panggilan Axios mentah langsung di dalam komponen React | 10 Domain Services bertipe TypeScript di frontend/src/services/ |
| Strategi Pengujian | Request spec integrasi yang rawan kebocoran data test | Unit test Use Case murni dengan isolasi DatabaseCleaner |
Dengan menerapkan Clean Architecture pada Ruby on Rails 8, kita mempertahankan produktivitas tinggi Rails dan Active Record sekaligus meraih keterpisahan layer tingkat enterprise, kemudahan pengujian, dan arsitektur frontend yang bersih.
Seluruh kode dari refactoring ini dapat dipelajari langsung di Pull Request #1 pada repositori GitHub. Pada Bagian 3, kita akan membedah pemodelan domain relasional dan perancangan skema PostgreSQL untuk platform CBT skala besar.