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
- God Models with Hundreds of Concerns:
Traditional Rails encourages pushing logic down into Active Record models. Before long,
User.rborExamination.rbaccumulates 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. - 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).
- 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.
- 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
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:successor:failurestates, 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)orUseCaseResult.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: Providesfind_by_email(email)andfind_admin.ExaminationRepository: Encapsulates exam queries, status filters, and question associations.ExamAttemptRepository: Providesfind_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:
- Authenticate the incoming HTTP request.
- Delegate parameters to the designated Use Case.
- 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:
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 Dimension | Before PR #1 (Monolithic Rails API) | After PR #1 (Clean Architecture) |
|---|---|---|
| Business Logic Location | Scattered inside controllers and Active Record models | Isolated in single-purpose Use Case classes |
| Database Queries | Raw ActiveRecord calls embedded across API controllers | Encapsulated within Repository classes |
| Result Contracts | Variable (boolean, exceptions, or custom JSON hashes) | Formalized UseCaseResult Monad (success? / failure?) |
| API Controllers | Heavy controllers (200+ LOC) with custom error rescuing | Ultra-thin controllers (< 40 LOC) delegating via handle_result |
| Frontend Communication | Ad-hoc Axios calls directly in React components | 10 strongly-typed Domain Services in frontend/src/services/ |
| Test Strategy | Slow integration request specs with database state leaks | Unit-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.