Skip to main content

Online Learning Platform

A course is a list of lessons, but "watched the video," "read the article," and "passed the quiz" are three completely different definitions of done. The design question isn't how to track progress - it's how to track it without Progress growing an if lesson is quiz for every lesson type anyone invents next.

Requirements

Functional

  • A course contains an ordered list of lessons; a lesson is a video, a text article, or a quiz.
  • A user enrolls in a course.
  • As the user works through lessons, each lesson's completion is tracked individually and rolls up into a course completion percentage.
  • A quiz lesson only counts as complete once the user passes it with a minimum score; a video or text lesson completes when it's been opened and viewed.

Non-functional

  • Adding a new lesson type must not require editing Progress or the course percent-complete calculation - each lesson type decides its own definition of "done."
  • Looking up a user's progress on a course must not scan every enrollment in the system.

Design

Every lesson type has a different rule for "did the learner finish this," so that rule lives on the lesson, not in whoever's asking. Progress never checks a lesson's type - it hands the learner's raw interaction to Lesson.isComplete(...) and trusts the answer, so a new lesson type is a new subclass, not a new branch somewhere else.

LearnerLearningPlatformLessonProgressCourseenroll(course)1recordActivity(lesson, data)2isComplete(data)3markComplete(lesson)4percentComplete(progress)5
  1. 1Enrollment is created once, up front, before any lesson has been touched.
  2. 2Every interaction - a finished video, a quiz submission - comes through this one method.
  3. 3The platform doesn’t know if this is a video or a quiz; the lesson answers for itself.
  4. 4A true answer gets recorded per lesson - no course-wide recompute happens here.
  5. 5The course percentage is a fold over already-recorded progress, not a re-derivation from scratch.

Enrollment is the thing that actually outlives a single lesson: it's created once at sign-up and accumulates one Progress entry per lesson as the learner moves through the course, which is also what lets completion percentage be read cheaply instead of recomputed by replaying every interaction.

Class diagram

«abstract»Lesson- id: string- title: string+ isComplete(data): boolLearningPlatform- enrollments: Map<enrollmentId, Enrollment>+ enroll(user, course): Enrollment+ recordActivity(enrollment, lessonId, data)Course- lessons: List<Lesson>+ percentComplete(progress): doubleVideoLesson- watchThreshold: doubleTextLessonQuizLesson- passingScore: intEnrollment- user: User- course: Course- progress: ProgressProgress- completedLessonIds: Set<string>+ markComplete(lessonId)+ isComplete(lessonId): boolUser- id: string- name: string
extendsusescreates
Lesson is polymorphic on isComplete; Progress and Enrollment never branch on lesson type.

Code

import java.util.*;
 
abstract class Lesson {
final String id;
final String title;
 
Lesson(String id, String title) {
this.id = id;
this.title = title;
}
 
abstract boolean isComplete(Object interactionData);
}
 
class VideoLesson extends Lesson {
private final double watchThreshold;
 
VideoLesson(String id, String title, double watchThreshold) {
super(id, title);
this.watchThreshold = watchThreshold;
}
 
boolean isComplete(Object interactionData) {
double watchedFraction = (double) interactionData;
return watchedFraction >= watchThreshold;
}
}
 
class TextLesson extends Lesson {
TextLesson(String id, String title) {
super(id, title);
}
 
boolean isComplete(Object interactionData) {
return Boolean.TRUE.equals(interactionData);
}
}
 
class QuizLesson extends Lesson {
private final int passingScore;
 
QuizLesson(String id, String title, int passingScore) {
super(id, title);
this.passingScore = passingScore;
}
 
boolean isComplete(Object interactionData) {
int score = (int) interactionData;
return score >= passingScore;
}
}
 
class Course {
final String id;
final List<Lesson> lessons;
 
Course(String id, List<Lesson> lessons) {
this.id = id;
this.lessons = lessons;
}
 
double percentComplete(Progress progress) {
long done = lessons.stream().filter(l -> progress.isComplete(l.id)).count();
return lessons.isEmpty() ? 0.0 : (100.0 * done) / lessons.size();
}
}
 
class Progress {
private final Set<String> completedLessonIds = new HashSet<>();
 
void markComplete(String lessonId) {
completedLessonIds.add(lessonId);
}
 
boolean isComplete(String lessonId) {
return completedLessonIds.contains(lessonId);
}
}
 
class User {
final String id;
final String name;
 
User(String id, String name) {
this.id = id;
this.name = name;
}
}
 
class Enrollment {
final User user;
final Course course;
final Progress progress = new Progress();
 
Enrollment(User user, Course course) {
this.user = user;
this.course = course;
}
}
 
class LearningPlatform {
private final Map<String, Enrollment> enrollments = new HashMap<>();
 
Enrollment enroll(User user, Course course) {
Enrollment enrollment = new Enrollment(user, course);
enrollments.put(user.id + ":" + course.id, enrollment);
return enrollment;
}
 
void recordActivity(Enrollment enrollment, String lessonId, Object interactionData) {
Lesson lesson = enrollment.course.lessons.stream()
.filter(l -> l.id.equals(lessonId))
.findFirst()
.orElseThrow();
if (lesson.isComplete(interactionData)) {
enrollment.progress.markComplete(lessonId);
}
}
}

Design decisions

  • Lesson is an abstract class with one polymorphic method, isComplete. VideoLesson checks a watched-percentage threshold, QuizLesson checks a passing score, TextLesson checks that it was opened. Progress calls the same method on all three and never knows which one it's talking to - that's what makes lesson type an implementation detail instead of a fact every caller has to know.
  • Progress stores one entry per (enrollment, lesson), not a single course-wide percentage. Recomputing a percentage from scratch on every read would mean walking every lesson every time; storing per-lesson completion means the course percentage is a cheap fold over data that's already there.
  • Enrollment is its own object rather than a field on User. A user can enroll in many courses, and progress belongs to the pairing of user and course, not to either one alone - keying progress lookups by enrollment id keeps that lookup O(1) instead of a scan of "every course this user has ever touched."
  • Quiz scoring is data on the QuizLesson, not a separate Quiz service. A quiz here is just a lesson with a passing threshold and a way to grade a submitted score; splitting it into a whole grading subsystem would be structure with nothing extra to justify it at this scope.
  • What's missing for a real system: lesson prerequisites/ordering enforcement, partial video-resume (seconds watched rather than a binary flag), and retaking a failed quiz with a cooldown are all real LMS features that slot into Lesson/Progress without changing the shape above - none of them need a new coordinating class.
0%0 of 122 pages studied