MUHAMMAD
FARI
MADYAN
[ Press ESC or Click to Skip ]

Rebuild Ruby on Rails + React E-Learning

1

Part 1: Pengenalan & Arsitektur: Dari Monolit ke Arsitektur Multithreaded Terpisah

2

Part 2: Penerapan Clean Architecture di Ruby on Rails & React: Repository Pattern, Use Cases, Result Monad & Typed Services (PR #1)

Artikel ini tersedia dalam Bahasa Inggris

🇬🇧 Read in English
🇮🇩 Bahasa Indonesia📚 Rebuild Ruby on Rails + React E-Learning

Ruby on Rails 8 + React E-Learning: Penerapan Clean Architecture, Repository Pattern, Use Cases & Typed Frontend Services (Bedah PR #1)

Analisis teknis mendalam mengenai refactoring platform E-Learning open-source Ruby on Rails 8.1 API & React 19 ke Clean Architecture: implementasi Repository Pattern, Use Cases bereputasi tunggal, Result Monad, typed service frontend, dan isolasi pengujian RSpec.

Muhammad Fari MadyanPenulis

12 menit baca

·

13 Oktober 2026


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

  1. 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.rb atau Examination.rb membengkak 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.
  2. 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).
  3. 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.
  4. "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

┌─────────────────────────────────────────────────────────────────────────────┐ │ ALUR BATAS LAPISAN CLEAN ARCHITECTURE │ │ │ │ [ React 19 Client ] │ │ │ (HTTP Request JSON / format camelCase) │ │ ▼ │ │ [ API Controller ] ──> Validasi autentikasi HTTP & delegasi parameter │ │ │ │ │ ▼ │ │ [ Use Case / Interactor ] <── Logika Bisnis Murni (Single Responsibility) │ │ │ │ │ ├──> Memanggil [ Repository Interface ] ──> Query Active Record │ │ └──> Mengembalikan [ UseCaseResult ] ────> (Monad :ok / :error) │ │ │ │ [ BaseController ] ──> Format respons HTTP terstandar + auto-camelize │ └─────────────────────────────────────────────────────────────────────────────┘

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 :success atau :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: Menyediakan find_by_email(email) dan find_admin.
  • ExaminationRepository: Mengenkapsulasi pencarian ujian, filter status aktif, dan asosiasi soal.
  • ExamAttemptRepository: Menyediakan find_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:

  1. Memvalidasi autentikasi HTTP request.
  2. Meneruskan parameter ke Use Case yang dituju.
  3. 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:

frontend/src/services/ ├── announcementService.ts # Pengumuman & siaran akademik ├── assignmentService.ts # Tugas siswa & penilaian guru ├── authService.ts # Login, registrasi, impersonasi, profil ├── classroomService.ts # Data kelas rombel belajar siswa ├── examService.ts # CBT runner, jawaban, anti-curang & monitoring ├── gradeService.ts # Buku nilai & rekapitulasi akademik ├── index.ts # Ekspor terpusat seluruh service ├── materialService.ts # Silabus & dokumen materi pembelajaran ├── studentService.ts # Manajemen data induk siswa ├── subjectService.ts # Mata pelajaran kurikulum └── teacherService.ts # Direktori guru & kredensial pengajar

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 ArsitekturSebelum PR #1 (Rails API Monolitik)Sesudah PR #1 (Clean Architecture)
Lokasi Logika BisnisTersebar di dalam controller dan Active Record modelTerisolasi di dalam kelas Use Case bereputasi tunggal
Query DatabaseQuery mentah ActiveRecord tersebar di berbagai controllerDienkapsulasi di dalam kelas Repository Pattern
Kontrak KembalianBervariasi (boolean, exception, atau hash JSON kustom)Terstandarisasi via UseCaseResult Monad (success? / failure?)
API ControllerController gemuk (200+ baris) dengan penyelamatan exceptionController sangat ramping (< 40 baris) melalui handle_result
Komunikasi FrontendPanggilan Axios mentah langsung di dalam komponen React10 Domain Services bertipe TypeScript di frontend/src/services/
Strategi PengujianRequest spec integrasi yang rawan kebocoran data testUnit 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.