MUHAMMAD
FARI
MADYAN
[ Press ESC or Click to Skip ]

Ruby on Rails + React E-Learning Rebuild

1

Part 1: Introduction & Architecture: Monolith to Decoupled Multithreaded Services

2

Part 2: Clean Architecture in Ruby on Rails & React: Repository Pattern, Use Cases, Result Monads & Typed Frontend Services (PR #1)

This article is available in Indonesian

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

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

A comprehensive deep dive into refactoring an open-source Ruby on Rails 8.1 API & React 19 E-Learning platform to Clean Architecture: implementing the Repository Pattern, single-responsibility Use Cases, Result Monads, typed frontend services, and isolated RSpec test suites.

Muhammad Fari MadyanAuthor

11 min read

·

Oct 13, 2026


The Architectural Challenge: The Rails "Fat Model" Dilemma

In Part 1 of this series, we explored migrating our monolithic PHP examination system to Ruby 3.3, Ruby on Rails 8.1 API, React 19, and PostgreSQL 16 (open-sourced at mfarim/ruby-rails-elearning-react). Rails brought immediate developer velocity: expressive database migrations, Puma's concurrent multithreading, and seamless JSON APIs.

However, as the application's complexity grew across multiple domains—Computer Based Testing (CBT), question banks, randomized shuffling, essay grading, and real-time proctoring—a classic Rails tension emerged:

"Where should complex business orchestration live when the default 'Skinny Controller, Fat Model' dogma starts collapsing under its own weight?"

Why the Default Rails Idiom Breaks Down in Enterprise Domains

  1. God Models with Hundreds of Concerns: Traditional Rails encourages pushing logic down into Active Record models. Before long, User.rb or Examination.rb accumulates hundreds of callbacks (before_save, after_commit), scopes, validations, and custom business calculations. Testing a simple discount calculation or exam attempt initialization requires instantiating an entire database-backed model with dozens of cascading associations.
  2. Coupling Business Logic to Persistence: In classic Rails, Active Record combines data persistence, validation, querying, and domain logic into a single object. When domain workflows need to orchestrate across five different models (e.g., verifying student enrollment, creating an attempt, generating randomized question permutations, and calculating timer offsets), putting that in a single model violates the Single Responsibility Principle (SRP).
  3. Controllers Laden with Database Queries: When developers avoid fat models without a formal architecture, controllers inevitably become bloated with raw Active Record queries, transaction blocks, and manual error formatting. This makes API endpoints difficult to unit test without heavy end-to-end integration setups.
  4. Ad-hoc "Service Objects" Without Boundaries: Many Rails teams adopt app/services/, but without architectural guardrails, these service classes quickly devolve into arbitrary procedural scripts that still leak Active Record scopes and lack standardized output contracts.

To solve this sustainably, I authored and merged Pull Request #1: refactor/clean-architecture-implementation. In this article, we break down the architectural design, patterns, and code changes that brought Uncle Bob's Clean Architecture to our Rails 8 + React e-learning platform.


Clean Architecture in Rails: Core Layers & Boundary Rules

┌─────────────────────────────────────────────────────────────────────────────┐ │ CLEAN ARCHITECTURE BOUNDARY FLOW │ │ │ │ [ React 19 Client ] │ │ │ (HTTP JSON Request / camelCase) │ │ ▼ │ │ [ API Controller ] ──> Validates HTTP auth & delegates params │ │ │ │ │ ▼ │ │ [ Use Case / Interactor ] <── Pure Business Logic (Single Responsibility) │ │ │ │ │ ├──> Calls [ Repository Interface ] ──> Queries Active Record │ │ └──> Returns [ UseCaseResult ] ────> (:ok / :error monad) │ │ │ │ [ BaseController ] ──> Formats HTTP response + auto-camelize │ └─────────────────────────────────────────────────────────────────────────────┘

The Dependency Rule states: source code dependencies must point inwards only toward high-level business policies.

In our implementation, the layers map cleanly to Rails directory structures:

  • Domain Entities & Models (app/models/): Represent our core data structures and relational integrity.
  • Repositories (app/repositories/): Encapsulate persistence logic and database queries away from business operations.
  • Use Cases / Interactors (app/use_cases/): Encapsulate specific application business workflows. They do not know about HTTP requests, cookies, or JSON serialization.
  • Interface Adapters (Controllers & Presenters): Translate between HTTP parameters, use cases, and standardized JSON output.
  • Result Contract (UseCaseResult): A functional monad returning explicit :success or :failure states, removing exception-based control flow.

1. The Result Monad: Predictable Railway-Oriented Programming

In many Rails applications, methods either raise exceptions or return arbitrary booleans, forcing controllers to perform messy begin...rescue or nil checks. We introduced 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

Why This Matters:

  • Every use case returns either UseCaseResult.success(data, status: :created) or UseCaseResult.failure("Reason", status: :forbidden).
  • HTTP status codes are determined at the business validation boundary (e.g., :not_found, :unauthorized, :unprocessable_entity), not guessed by the controller.

2. The Repository Pattern: Isolating Database Queries

Repositories isolate Active Record query calls so that use cases interact with domain collections rather than raw SQL methods.

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

Domain-Specific Repositories:

We implemented focused repositories for our critical entities:

  • UserRepository: Provides find_by_email(email) and find_admin.
  • ExaminationRepository: Encapsulates exam queries, status filters, and question associations.
  • ExamAttemptRepository: Provides find_or_initialize_for(exam, student) to handle candidate exam attempt lifecycle.
  • AssignmentRepository & ClassroomRepository: Manage course materials and student class enrollments.

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: Encapsulating Pure Business Workflows

Each use case has exactly one responsibility and exposes a single .call method. Let's examine two critical workflows: Authentication and Exam Attempt Management.

Authentication Use Case (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("Invalid email or password", status: :unauthorized) unless user
      return UseCaseResult.failure("Account is inactive", status: :forbidden) unless user.active
      return UseCaseResult.failure("Invalid email or password", 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

CBT Exam Initialization (backend/app/use_cases/exams/start_exam_attempt.rb):

Starting an online examination is full of edge cases: verifying candidate permissions, determining if retries are permitted, managing remaining timer countdowns, shuffling questions and multiple-choice options according to exam policy, and restoring in-progress answers if a student accidentally refreshes their browser.

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("Student record not found", status: :forbidden) unless student

      exam = @exam_repo.find(exam_id)
      attempt = @attempt_repo.find_or_initialize_for(exam, student)

      # Handle fresh attempt or permitted retries
      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

      # Shuffling logic isolated from 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

      # Calculate exact remaining timer based on wall-clock started_at
      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

Automated Exam Submission & Scoring (backend/app/use_cases/exams/submit_exam_attempt.rb):

When a candidate submits their exam, the use case grades multiple-choice answers instantaneously, tallies earned points, calculates pass/fail thresholds, and flags the attempt if manual teacher essay grading is required:

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("Exam attempt already finished") 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 Controllers & Automated Serialization

Controllers in our refactored architecture have exactly three duties:

  1. Authenticate the incoming HTTP request.
  2. Delegate parameters to the designated Use Case.
  3. Call handle_result(result) to render standardized JSON.

Base Controller Helper (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

The New Exam Runner Controller (backend/app/controllers/api/v1/exam_runner_controller.rb):

Before PR #1, this controller had over 260 lines of imperative SQL queries and nested loops. Notice how clean it is now:

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: "Answer saved")
      end

      def submit
        require_student!
        return if performed?

        result = submit_exam_attempt_use_case.call(attempt_id: params[:attempt_id])
        handle_result(result, message: "Exam submitted successfully")
      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. Frontend Clean Architecture: The Typed Service Layer

Clean architecture isn't only for backend code. On the frontend, having React components directly call api.get('/exams/' + id) or api.post(...) tightly couples view components to API endpoint URLs, query strings, and payload shapes.

In PR #1, we created a Typed Service Layer (frontend/src/services/) consisting of 10 dedicated domain modules:

frontend/src/services/ ├── announcementService.ts # Announcements & school broadcasts ├── assignmentService.ts # Student homework submissions & teacher grading ├── authService.ts # Login, registration, impersonation, profile ├── classroomService.ts # Academic cohorts, classes, student lists ├── examService.ts # CBT runner, answers, anti-cheat & monitor telemetry ├── gradeService.ts # Report cards and academic analytics ├── index.ts # Centralized service barrel export ├── materialService.ts # Subject syllabus & PDF lesson attachments ├── studentService.ts # Student management & enrollment ├── subjectService.ts # Academic curriculum subjects └── teacherService.ts # Faculty member directory & credentials

Example: Typed CBT Service (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;
  },
};

Component Consumption Before vs. After:

In ExamRunner.tsx and ExamMonitor.tsx, components no longer construct Axios requests or handle raw envelope parsing:

TYPESCRIPT

// BEFORE: Direct Axios calls scattered in useEffect
const res = await api.post(`/exams/${examId}/start`);
setExamData(res.data.data);

// AFTER: Clean, typed service call
const examData = await examService.startExam(Number(examId));
setExamData(examData);

6. Test Suite Isolation with RSpec & DatabaseCleaner

One of the greatest benefits of Clean Architecture is lightning-fast, isolated unit testing.

Because use cases depend on repository interfaces and return plain UseCaseResult objects, we can test business logic directly without running the entire Rails HTTP stack.

Testing Authentication Workflows (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: 'Teacher One', email: '[email protected]', password: 'password123', role: 'ROLE_TEACHER') }

  describe '#call' do
    it 'returns success with token payload on valid credentials' 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 'returns unauthorized failure on incorrect password' do
      result = use_case.call(email: '[email protected]', password: 'wrongpassword')

      expect(result).to be_failure
      expect(result.status).to eq(:unauthorized)
      expect(result.error).to eq("Invalid email or password")
    end
  end
end

Zero State Bleed with DatabaseCleaner (backend/spec/rails_helper.rb):

To prevent test pollution across transactional specs and asynchronous background jobs, we configured 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

Summary of Changes & Engineering Takeaways

Architectural DimensionBefore PR #1 (Monolithic Rails API)After PR #1 (Clean Architecture)
Business Logic LocationScattered inside controllers and Active Record modelsIsolated in single-purpose Use Case classes
Database QueriesRaw ActiveRecord calls embedded across API controllersEncapsulated within Repository classes
Result ContractsVariable (boolean, exceptions, or custom JSON hashes)Formalized UseCaseResult Monad (success? / failure?)
API ControllersHeavy controllers (200+ LOC) with custom error rescuingUltra-thin controllers (< 40 LOC) delegating via handle_result
Frontend CommunicationAd-hoc Axios calls directly in React components10 strongly-typed Domain Services in frontend/src/services/
Test StrategySlow integration request specs with database state leaksUnit-tested use cases with isolated DatabaseCleaner truncation

By applying Clean Architecture to Ruby on Rails 8, we retain Rails' legendary developer velocity and Active Record convenience while gaining enterprise maintainability, test isolation, and decoupled frontend architecture.

All code from this refactor is available in Pull Request #1 on GitHub. In Part 3, we dive into domain modeling and PostgreSQL schema design for enterprise CBT platforms.