The Engineering Realities of Online Examinations
In high-stakes academic examinations, Computer Based Testing (CBT) engines face hostile operating conditions that traditional enterprise business software never encounters:
- Client Clock Tampering: Candidates can modify browser clocks, freeze JavaScript event loops, or manipulate local storage to artificially prolong testing time.
- Network Volatility in School Labs: If a local Wi-Fi router experiences intermittent packet loss, submitting an entire 50-question exam in a single final POST request invites catastrophic data loss.
- Real-Time Proctoring at Scale: Teachers and invigilators supervising 200+ candidates simultaneously require live telemetry (current question index, remaining time, violation counters) without hammering the server through aggressive 2-second HTTP polling.
Following the Clean Architecture foundation established in Part 2 of this series, we engineered our CBT engine in mfarim/ruby-rails-elearning-react using decoupled, single-responsibility Use Cases, server-authoritative timekeeping, and Action Cable real-time broadcasting.
1. Clean Architecture CBT Use Cases
Rather than letting controllers execute database operations directly or stuffing Active Record models with sprawling callbacks, the exam domain is orchestrated through isolated Use Cases returning standardized UseCaseResult objects.
A. Authoritative Server Timekeeper (Exams::StartExamAttempt)
When a candidate starts an exam via POST /api/v1/exams/:id/start, Exams::StartExamAttempt initializes the session and computes remaining duration strictly based on the server's master clock:
RUBY
# backend/app/use_cases/exams/start_exam_attempt.rb
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)
# 1. Initialize attempt on new record or allowed 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
# 2. Randomize question order if configured
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
# 3. Server-authoritative remaining time calculation
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
Why This Prevents Clock Manipulation:
Even if a candidate pauses browser execution or manipulates their local system clock, reopening the exam triggers a server-side recalculation: $$\text{Remaining Seconds} = \max\left(0, (\text{Duration} \times 60) - (\text{Time.current} - \text{attempt.started_at})\right)$$ If the delta reaches zero, any subsequent answer submission is immediately rejected by the backend.
B. Instant Autosave (Exams::SaveExamAnswer)
Submitting an exam should not be a risky all-at-once operation. In Exams::SaveExamAnswer, every option selection is persisted atomically:
RUBY
# backend/app/use_cases/exams/save_exam_answer.rb
module Exams
class SaveExamAnswer
def initialize(attempt_repo: ExamAttemptRepository.new)
@attempt_repo = attempt_repo
end
def call(attempt_id:, question_id:, answer:)
attempt = @attempt_repo.find(attempt_id)
if attempt.status != 'in_progress'
return UseCaseResult.failure("Exam attempt is not in progress", status: :unprocessable_entity)
end
# Atomic Upsert of student answer
ans = ExamAnswer.find_or_initialize_by(
exam_attempt: attempt,
question_id: question_id
)
ans.student_answer = answer
ans.save!
# Push live progress update to Action Cable
ActionCable.server.broadcast(
"exam_monitor_#{attempt.examination_id}",
{
eventType: "ANSWER_SAVED",
studentId: attempt.student_id,
answeredCount: attempt.exam_answers.count
}
)
UseCaseResult.success({ saved: true, questionId: question_id })
rescue ActiveRecord::RecordNotFound => e
UseCaseResult.failure(e.message, status: :not_found)
end
end
end
C. Automated Scoring & Essay Flagging (Exams::SubmitExamAttempt)
When the exam concludes (or the hard deadline expires), Exams::SubmitExamAttempt computes scores, evaluates passing status (KKM), and routes essay questions for teacher grading:
RUBY
# backend/app/use_cases/exams/submit_exam_attempt.rb
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
questions = exam.questions.index_by(&:id)
answers = attempt.exam_answers.includes(:question)
total_points = questions.values.sum(&:points)
earned_points = 0.0
has_essay = false
# Grade objective questions automatically
answers.each do |ans|
q = ans.question
next unless q
if q.question_type == 'multiple_choice' || q.question_type == 'true_false'
is_correct = ans.student_answer.to_s.strip.downcase == q.correct_answer.to_s.strip.downcase
ans.update!(
is_correct: is_correct,
points_awarded: is_correct ? q.points : 0.0,
graded_at: Time.current
)
earned_points += q.points if is_correct
elsif q.question_type == 'essay'
has_essay = true
end
end
score_pct = total_points > 0 ? (earned_points / total_points * 100.0).round(2) : 0.0
passed = score_pct >= exam.passing_score
# If essay questions exist, status transitions to 'needs_grading'
attempt.update!(
score: score_pct,
passed: passed,
finished_at: Time.current,
status: has_essay ? 'needs_grading' : 'completed',
remaining_seconds: 0
)
payload = {
score: attempt.score,
passed: attempt.passed,
status: attempt.status,
totalQuestions: questions.size,
answeredCount: answers.size
}
UseCaseResult.success(payload)
rescue ActiveRecord::RecordNotFound => e
UseCaseResult.failure(e.message, status: :not_found)
end
end
end
2. Anti-Cheat Violation Trapping
In backend/app/controllers/api/v1/exam_runner_controller.rb, we intercept cheating infractions detected on the candidate's browser:
RUBY
# backend/app/controllers/api/v1/exam_runner_controller.rb
def violation
require_student!
return if performed?
attempt = exam_attempt_repository.find(params[:attempt_id])
attempt.increment!(:violations_count)
# Hard auto-submit when violations hit threshold
force_submitted = false
if attempt.violations_count >= 5
attempt.update!(status: 'force_finished', finished_at: Time.current)
force_submitted = true
end
# Broadcast violation alert to invigilators via Action Cable
ActionCable.server.broadcast(
"exam_monitor_#{attempt.examination_id}",
{
eventType: "VIOLATION_LOGGED",
studentId: attempt.student_id,
violationsCount: attempt.violations_count,
forceSubmitted: force_submitted
}
)
json_response(
data: { violations: attempt.violations_count, forceSubmitted: force_submitted },
message: "Violation recorded"
)
end
Candidate Client Lockdown (ExamRunner.tsx)
On the React frontend, listeners detect if the student leaves fullscreen or switches tabs:
TYPESCRIPT
// frontend/src/pages/exams/ExamRunner.tsx
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
reportViolation("TAB_SWITCH_DETECTED");
}
};
const handleFullscreenChange = () => {
if (!document.fullscreenElement) {
reportViolation("FULLSCREEN_EXITED");
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
document.removeEventListener("fullscreenchange", handleFullscreenChange);
};
}, []);
3. Real-Time Telemetry via Action Cable & Redis
To support live supervision without database polling, Rails 8 integrates Action Cable seamlessly. In backend/config/cable.yml:
YAML
# backend/config/cable.yml
development:
adapter: async
test:
adapter: test
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: elearning_production
In production, Action Cable uses Redis Pub/Sub as the backplane. When a Puma worker saves an answer or logs a violation, it publishes the event to Redis, which immediately broadcasts it to all connected teachers viewing ExamMonitor.tsx:
TYPESCRIPT
// frontend/src/pages/exams/ExamMonitor.tsx
useEffect(() => {
const cable = createConsumer(`wss://${window.location.host}/cable`);
const subscription = cable.subscriptions.create(
{ channel: "ExamChannel", exam_id: examId },
{
received(data) {
if (data.eventType === "VIOLATION_LOGGED") {
flashCandidateViolation(data.studentId, data.violationsCount);
} else if (data.eventType === "ANSWER_SAVED") {
updateProgressBar(data.studentId, data.answeredCount);
}
}
}
);
return () => subscription.unsubscribe();
}, [examId]);
Key Takeaways for Engineering Leads
- Decouple domain workflows from HTTP transport: By implementing
StartExamAttempt,SaveExamAnswer, andSubmitExamAttemptas standalone Use Cases, exam business logic can be tested in sub-second isolation using in-memory RSpec unit tests. - Never calculate deadlines client-side: Store an immutable UTC
started_attimestamp on the attempt and compute remaining seconds dynamically on the server. - Use Action Cable with Redis for scalable proctoring: Eliminating 2-second HTTP polling from 500 active candidates reduces database connection saturation by over 90%.
In Part 4, we explore data ingestion and DevSecOps: High-Throughput Spreadsheet Parsing with the roo Gem, Stateless JWT Admin Impersonation, and Automated Security Auditing with Brakeman & Bundler-Audit.

