Design a learning management system¶
TL;DR¶
- You build a course tree (course to module to lesson), a set of Visitors that ask it questions, and services for enrollment, grading and progress.
- Three decisions carry the interview: the tree is a Composite and every question about it is a Visitor (duration, progress, outline), a seat is claimed before the payment so a full course never oversells, and content is handed out behind a Proxy so a draft is invisible even if you know the URL.
- Patterns that earn their place: Composite, Visitor, Proxy, Factory, Strategy, Observer, Repository. A pricing Strategy is discussed and deliberately not used.
Problem statement¶
"Design a learning platform. Instructors build courses out of modules and lessons - videos, articles, quizzes and assignments - and publish them. Students enroll (free or paid), work through the lessons, take quizzes that grade themselves and hand in assignments that an instructor grades. The system tracks progress, issues a certificate at 100 percent, and notifies people. Courses have a capacity and prerequisites. Tell me what happens when the last seat is taken by two students at once, and who can see a course that has not been published yet."
Requirements¶
Functional
- Courses contain modules; modules contain lessons: video, article, quiz and assignment.
- Instructors create courses as drafts, publish them and archive them.
- Enrollment: free or paid, capacity-limited, with prerequisite courses; a waiting list when full.
- Progress tracking per student: lessons completed, minutes done, percent.
- Quizzes are auto-graded with a pass mark, an attempt limit and a time limit; assignments are graded by a human.
- A certificate when everything in the course is finished.
- Discussions attached to lessons, and notifications for enrollment, grading and certificates.
- Roles and permissions: student, instructor, admin.
Non-functional and constraints
- Capacity is never exceeded, whatever the interleaving of concurrent enrollments.
- Unpublished content is unreachable for anyone but its author and admins, on every access path.
- Progress stays sane when an instructor edits a published course.
- Deterministic and testable: injected clock and IDs, in-memory repositories behind protocols.
Out of scope: video hosting and playback (that is the YouTube or Netflix problem), payment integration beyond a gateway interface, recommendations, live classes, plagiarism detection.
Clarifying questions and assumptions¶
| Question to ask | Assumption taken here |
|---|---|
| How deep does the content tree go? | Three levels today, but modelled as a Composite so a "chapter" between them costs nothing. |
| Does submitting an assignment complete it? | No. Only a passing grade does, which is exactly what separates it from a quiz. |
| What happens when the course is full? | The student is waitlisted, not rejected, and promoted when a seat opens. A constructor flag turns this into a hard refusal. |
| Is a paid enrollment active before the money arrives? | No: PENDING holds the seat, ACTIVE follows the payment, and a decline frees the seat. |
| What counts as progress? | Lessons completed against the tree as it is now. Percent is derived, never stored. |
| Who can see a draft? | Its instructor and admins. Archived courses stay open to people already enrolled. |
| Do students see each other's grades? | No. Grading is permission-checked and a peer cannot grade a submission. |
Core entities and relationships¶
- ContentNode (abstract) with
Course,ModuleandLesson;LessonhasVideoLesson,ArticleLesson,QuizandAssignment.LessonFactorybuilds a leaf from imported data. - ContentVisitor with
DurationVisitor,ProgressVisitorandOutlineVisitor— three questions, zero changes to the tree. - CourseAccessProxy — the same interface as
Course, permission-checked on every call. - CourseCatalog — the repository plus the publish/archive transitions; PermissionService — who may view, edit and grade.
- EnrollmentService — seats, prerequisites, payment and the waiting list; it owns one lock per course.
- Enrollment — student, course,
EnrollmentStatus, and the set of completed lesson ids. - LearningService — what a student does after enrolling: complete lessons, take quizzes, submit assignments, receive certificates. One lock per enrollment.
- GradingStrategy with
AutoGrader(quizzes) andManualGrader(assignments); Grade, QuizAttempt, Submission, Certificate, Progress. - NotificationService — an observer of learning events.
Multiplicities: course 1 -> * modules, module 1 -> * lessons, course 1 -> * enrollments (bounded by capacity), enrollment 1 -> * completed lessons, quiz 1 -> * questions, student 1 -> * attempts per quiz (bounded by max_attempts).
Class diagram¶
The content tree and the visitors that walk it.
classDiagram
class ContentNode {
<<abstract>>
+id: str
+title: str
+accept(visitor) Any
+children() List~ContentNode~
+lessons() Iterator~Lesson~
+find(node_id) ContentNode
}
class Course {
+instructor_id: str
+capacity: int
+price: Money
+prerequisites: tuple
+status: PublishStatus
+add(module) Course
}
class Module {
+add(lesson) Module
}
class Lesson {
<<abstract>>
+kind: LessonKind
+duration_minutes: int
}
class VideoLesson
class ArticleLesson
class Quiz {
+questions: List~Question~
+pass_mark: float
+max_attempts: int
+time_limit_minutes: int
}
class Assignment {
+max_points: int
+pass_mark: float
}
class LessonFactory {
+create(kind, node_id, title) Lesson
}
class ContentVisitor {
<<interface>>
+visit_course(course) object
+visit_module(module) object
+visit_quiz(quiz) object
}
class DurationVisitor
class ProgressVisitor
class OutlineVisitor
ContentNode <|-- Course
ContentNode <|-- Module
ContentNode <|-- Lesson
Lesson <|-- VideoLesson
Lesson <|-- ArticleLesson
Lesson <|-- Quiz
Lesson <|-- Assignment
Course "1" *-- "many" Module
Module "1" *-- "many" Lesson
LessonFactory ..> Lesson : creates
ContentVisitor <|.. DurationVisitor
ContentVisitor <|.. ProgressVisitor
ContentVisitor <|.. OutlineVisitor
ContentNode ..> ContentVisitor : accept
The services: enrollment, grading, permissions and the proxy.
classDiagram
class LearningService {
-locks: Dict~str,RLock~
+open_course(course_id, viewer) CourseAccessProxy
+complete_lesson(course_id, student, lesson_id) Progress
+start_attempt(course_id, student, quiz_id) QuizAttempt
+submit_attempt(course_id, student, attempt_id, answers) Grade
+submit_assignment(course_id, student, assignment_id, text) Submission
+grade_submission(course_id, grader, submission_id, points) Grade
+certificate(course_id, student_id) Certificate
}
class EnrollmentService {
-course_locks: Dict~str,Lock~
+enroll(course, student) Enrollment
+cancel(enrollment_id) Enrollment
+is_enrolled(course_id, student_id) bool
}
class CourseCatalog {
+add(course) Course
+get(course_id) Course
+set_status(course_id, status) Course
}
class PermissionService {
+can_view(viewer, course) bool
+require_view(viewer, course) None
+require_grade(viewer, course) None
}
class CourseAccessProxy {
+accept(visitor) Any
+children() List~ContentNode~
+subject: Course
}
class Enrollment {
+status: EnrollmentStatus
+completed_lesson_ids: Set~str~
+takes_a_seat() bool
}
class GradingStrategy {
<<interface>>
+grade(lesson, work, at) Grade
}
class AutoGrader
class ManualGrader {
+apply(assignment, points, grader_id, at) Grade
}
class PaymentGateway {
<<interface>>
+charge(student_id, amount) bool
}
class LearningObserver {
<<interface>>
+on_learning_event(event, recipients, detail) None
}
class NotificationService
class Certificate
class Progress
LearningService --> CourseCatalog
LearningService --> EnrollmentService
LearningService --> PermissionService
LearningService ..> CourseAccessProxy : hands out
LearningService ..> GradingStrategy
LearningService ..> Certificate : issues
LearningService ..> Progress : computes
EnrollmentService "1" o-- "many" Enrollment
EnrollmentService --> PaymentGateway
EnrollmentService --> "many" LearningObserver
CourseAccessProxy --> PermissionService
GradingStrategy <|.. AutoGrader
GradingStrategy <|.. ManualGrader
LearningObserver <|.. NotificationService
Design patterns applied¶
| Pattern | Where | Why it earns its place |
|---|---|---|
| Composite | ContentNode with Course, Module, Lesson |
"Total duration" and "percent complete" are the same recursion whatever the depth. Adding a chapter level between module and lesson touches no caller. |
| Visitor | DurationVisitor, ProgressVisitor, OutlineVisitor |
Three questions that would otherwise be three methods on six classes. The tree is closed and the operations are open - the textbook trade, and here the trade is right because new questions are frequent and new node types are rare. |
| Proxy | CourseAccessProxy |
Protection proxy: it has the same interface as Course and re-checks permission on every call, so there is no path that forgets the check. That matters more than it sounds - the usual bug is one endpoint that reads the course directly. |
| Factory Method | LessonFactory, grading_for |
Imported course data is a kind string; the factory is the only place that maps strings to classes. |
| Strategy | GradingStrategy with AutoGrader and ManualGrader |
Both are called the same way; the difference is that one returns a Grade and the other returns None, meaning "a human has to look at this". One call site, two behaviours. |
| Observer | LearningObserver -> NotificationService |
Enrollment and grading announce; email, push and audit subscribe. Announcements happen outside every lock. |
| Repository | CourseCatalog, the enrollment store |
Swap for SQL without touching a service. |
| State (lightweight) | EnrollmentStatus, PublishStatus |
Five and three values with guarded transitions. Classes would be ceremony. |
What was deliberately not used: a pricing Strategy. The brief invites one, but a course has a single price field today; a strategy family with one implementation is speculative generality. Say what would change your mind: the moment coupons, regional pricing or subscriptions appear, price becomes PricingStrategy.quote(student, course) and the enrollment code is unchanged because it already asks the course, not a table.
Key flows¶
Enroll, learn, finish: the seat is held before the money moves, and progress is recomputed from the tree.
sequenceDiagram
autonumber
actor S as Student
participant E as EnrollmentService
participant P as PaymentGateway
participant L as LearningService
participant X as CourseAccessProxy
participant V as ProgressVisitor
S->>E: enroll(course, student)
E->>E: prerequisites, then claim a seat under the course lock
alt no seat left
E-->>S: Enrollment WAITLISTED
else seat held as PENDING
E->>P: charge(student, price)
alt declined
P-->>E: False
E->>E: cancel, the seat goes straight back
E-->>S: PaymentDeclinedError
else approved
P-->>E: True
E-->>S: Enrollment ACTIVE
S->>L: complete_lesson(course, lesson)
L->>X: open_course, permission check
L->>V: course.accept(ProgressVisitor(done))
V-->>L: Progress 100 percent
L-->>S: certificate issued
end
end
Enrollment lifecycle. PENDING is the seat reservation, WAITLISTED holds no seat, and only ACTIVE can become COMPLETED.
stateDiagram-v2
[*] --> PENDING : seat held, payment in flight
[*] --> WAITLISTED : course full
PENDING --> ACTIVE : payment approved or course is free
PENDING --> CANCELLED : payment declined
WAITLISTED --> ACTIVE : a seat opened on a free course
WAITLISTED --> PENDING : a seat opened on a paid course
ACTIVE --> COMPLETED : last lesson finished
ACTIVE --> CANCELLED : student leaves
WAITLISTED --> CANCELLED : student gives up
COMPLETED --> [*]
CANCELLED --> [*]
Implementation¶
Write the tree first. Everything else in this design is a question asked of it.
The enums fix the vocabulary; EnrollmentStatus is the one to write on the board, because PENDING versus WAITLISTED is the whole seat story.
class Role(StrEnum):
STUDENT = "student"
INSTRUCTOR = "instructor"
ADMIN = "admin"
class PublishStatus(StrEnum):
DRAFT = "draft" # only the author and admins can see it
PUBLISHED = "published"
ARCHIVED = "archived" # visible to people already enrolled, closed to new ones
class EnrollmentStatus(StrEnum):
PENDING = "pending" # paid course, payment in flight
ACTIVE = "active"
WAITLISTED = "waitlisted" # the course was full when this student arrived
COMPLETED = "completed"
CANCELLED = "cancelled"
class LessonKind(StrEnum):
VIDEO = "video"
ARTICLE = "article"
QUIZ = "quiz"
ASSIGNMENT = "assignment"
class SubmissionStatus(StrEnum):
SUBMITTED = "submitted"
GRADED = "graded"
class PermissionDeniedError(HandbookError):
"""The viewer's role does not allow this on this course."""
class NotPublishedError(InvalidStateError):
"""The course is still a draft; only its instructor and admins may open it."""
class CourseFullError(ConflictError):
"""Capacity is taken. The student is offered the waiting list instead."""
class PrerequisiteError(ConflictError):
"""A required course has not been completed."""
class PaymentDeclinedError(ConflictError):
"""The card was refused; the seat that was held for the student is released."""
class AlreadyEnrolledError(ConflictError):
"""One live enrollment per student per course."""
class EnrollmentStateError(InvalidStateError):
"""The enrollment is not in a state that allows this operation."""
class AttemptLimitError(ConflictError):
"""The student has used every attempt this quiz allows."""
class AttemptWindowError(InvalidStateError):
"""The attempt was submitted after the quiz's time limit ran out."""
class ContentNotFoundError(NotFoundError):
"""Unknown course, module, lesson or enrollment id."""
The flat data: who is enrolled, what they answered, what they earned. Note Progress is a computed snapshot with a derived percent, never a stored counter that can drift.
@dataclass(frozen=True, slots=True)
class User:
id: str
name: str
role: Role = Role.STUDENT
@dataclass(frozen=True, slots=True)
class Question:
id: str
prompt: str
options: tuple[str, ...]
correct_index: int
points: int = 1
def __post_init__(self) -> None:
if not 0 <= self.correct_index < len(self.options):
raise ValidationError(f"question {self.id}: correct_index is not one of the options")
if self.points < 1:
raise ValidationError(f"question {self.id}: points must be positive")
def is_correct(self, answer: int | None) -> bool:
return answer == self.correct_index
@dataclass(frozen=True, slots=True)
class Grade:
points: int
max_points: int
graded_by: str
graded_at: float
passed: bool
@property
def percent(self) -> float:
return 0.0 if self.max_points == 0 else round(100 * self.points / self.max_points, 1)
def __str__(self) -> str:
return f"{self.points}/{self.max_points} ({self.percent}%) {'pass' if self.passed else 'fail'}"
@dataclass(slots=True)
class QuizAttempt:
id: str
quiz_id: str
student_id: str
started_at: float
answers: dict[str, int] = field(default_factory=dict)
submitted_at: float | None = None
grade: Grade | None = None
@dataclass(slots=True)
class Submission:
id: str
assignment_id: str
student_id: str
text: str
submitted_at: float
status: SubmissionStatus = SubmissionStatus.SUBMITTED
grade: Grade | None = None
@dataclass(slots=True)
class Enrollment:
id: str
course_id: str
student_id: str
status: EnrollmentStatus
enrolled_at: float
price: Money = FREE
completed_lesson_ids: set[str] = field(default_factory=set)
completed_at: float | None = None
def is_live(self) -> bool:
return self.status in (
EnrollmentStatus.PENDING,
EnrollmentStatus.ACTIVE,
EnrollmentStatus.WAITLISTED,
EnrollmentStatus.COMPLETED,
)
def takes_a_seat(self) -> bool:
"""Waitlisted students hold no seat; everyone else who is live does."""
return self.status in (
EnrollmentStatus.PENDING,
EnrollmentStatus.ACTIVE,
EnrollmentStatus.COMPLETED,
)
def activate(self) -> None:
if self.status not in (EnrollmentStatus.PENDING, EnrollmentStatus.WAITLISTED):
raise EnrollmentStateError(f"enrollment {self.id} is {self.status}, cannot activate")
self.status = EnrollmentStatus.ACTIVE
@dataclass(frozen=True, slots=True)
class Progress:
"""A snapshot, computed against the course tree as it exists right now."""
completed: int
total: int
minutes_done: int
minutes_total: int
@property
def percent(self) -> float:
return 0.0 if self.total == 0 else round(100 * self.completed / self.total, 1)
def is_finished(self) -> bool:
return self.total > 0 and self.completed == self.total
def __str__(self) -> str:
return f"{self.completed}/{self.total} lessons ({self.percent}%), {self.minutes_done}/{self.minutes_total} min"
@dataclass(frozen=True, slots=True)
class Certificate:
id: str
course_id: str
student_id: str
issued_at: float
score_percent: float
@dataclass(frozen=True, slots=True)
class DiscussionPost:
id: str
lesson_id: str
author_id: str
body: str
posted_at: float
The Composite. accept is the only thing every node must implement; children is what makes composites composite, and a leaf simply returns nothing.
class ContentNode(ABC):
"""Composite component. Everything in the tree answers these four questions."""
def __init__(self, node_id: str, title: str) -> None:
if not title.strip():
raise ValidationError("content needs a title")
self.id = node_id
self.title = title
@abstractmethod
def accept(self, visitor: ContentVisitor) -> Any:
"""Double dispatch: the node names itself, the visitor decides what to do."""
def children(self) -> list[ContentNode]:
return []
def lessons(self) -> Iterator[Lesson]:
for child in self.children():
yield from child.lessons()
def find(self, node_id: str) -> ContentNode:
for node in self.walk():
if node.id == node_id:
return node
raise ContentNotFoundError(f"{node_id} is not in {self.id}")
def walk(self) -> Iterator[ContentNode]:
yield self
for child in self.children():
yield from child.walk()
class Lesson(ContentNode):
"""Composite leaf. ``duration_minutes`` is what the duration visitor sums."""
kind: ClassVar[LessonKind]
def __init__(self, node_id: str, title: str, duration_minutes: int) -> None:
super().__init__(node_id, title)
if duration_minutes < 0:
raise ValidationError(f"lesson {node_id}: duration cannot be negative")
self.duration_minutes = duration_minutes
def lessons(self) -> Iterator[Lesson]:
yield self
def __repr__(self) -> str:
return f"{type(self).__name__}({self.id!r})"
class Module(ContentNode):
def __init__(self, node_id: str, title: str, lessons: list[Lesson] | None = None) -> None:
super().__init__(node_id, title)
self._lessons: list[Lesson] = list(lessons or [])
def add(self, lesson: Lesson) -> Module:
self._lessons.append(lesson)
return self
def children(self) -> list[ContentNode]:
return list(self._lessons)
def accept(self, visitor: ContentVisitor) -> Any:
return visitor.visit_module(self)
class Course(ContentNode):
"""The composite root, plus the enrollment rules that belong to the course itself."""
def __init__(
self,
node_id: str,
title: str,
instructor_id: str,
capacity: int = 100,
price: Money = FREE,
prerequisites: tuple[str, ...] = (),
modules: list[Module] | None = None,
) -> None:
super().__init__(node_id, title)
if capacity < 1:
raise ValidationError(f"course {node_id}: capacity must be positive")
self.instructor_id = instructor_id
self.capacity = capacity
self.price = price
self.prerequisites = prerequisites
self.status = PublishStatus.DRAFT
self._modules: list[Module] = list(modules or [])
def add(self, module: Module) -> Course:
self._modules.append(module)
return self
def children(self) -> list[ContentNode]:
return list(self._modules)
def accept(self, visitor: ContentVisitor) -> Any:
return visitor.visit_course(self)
def is_free(self) -> bool:
return self.price.is_zero()
def is_open(self) -> bool:
return self.status is PublishStatus.PUBLISHED
The four leaf types, and the factory that builds one from imported data. ArticleLesson derives its duration from its length instead of storing a number someone has to maintain.
class VideoLesson(Lesson):
kind = LessonKind.VIDEO
def __init__(self, node_id: str, title: str, duration_minutes: int, url: str = "") -> None:
super().__init__(node_id, title, duration_minutes)
self.url = url
def accept(self, visitor: ContentVisitor) -> Any:
return visitor.visit_video(self)
class ArticleLesson(Lesson):
"""Its duration is derived, not stored: reading speed times length."""
kind = LessonKind.ARTICLE
def __init__(self, node_id: str, title: str, word_count: int) -> None:
super().__init__(node_id, title, max(1, round(word_count / WORDS_PER_MINUTE)))
self.word_count = word_count
def accept(self, visitor: ContentVisitor) -> Any:
return visitor.visit_article(self)
class Quiz(Lesson):
"""Auto-graded: the answers are in the questions, so no human is involved."""
kind = LessonKind.QUIZ
def __init__(
self,
node_id: str,
title: str,
questions: list[Question],
pass_mark: float = 70.0,
max_attempts: int = 3,
time_limit_minutes: int = 20,
) -> None:
super().__init__(node_id, title, time_limit_minutes)
if not questions:
raise ValidationError(f"quiz {node_id} needs at least one question")
self.questions = list(questions)
self.pass_mark = pass_mark
self.max_attempts = max_attempts
self.time_limit_minutes = time_limit_minutes
@property
def max_points(self) -> int:
return sum(question.points for question in self.questions)
def accept(self, visitor: ContentVisitor) -> Any:
return visitor.visit_quiz(self)
class Assignment(Lesson):
"""Manually graded: submitting is not finishing, an instructor has to grade it."""
kind = LessonKind.ASSIGNMENT
def __init__(
self, node_id: str, title: str, max_points: int = 100, pass_mark: float = 50.0, effort_minutes: int = 60
) -> None:
super().__init__(node_id, title, effort_minutes)
self.max_points = max_points
self.pass_mark = pass_mark
def accept(self, visitor: ContentVisitor) -> Any:
return visitor.visit_assignment(self)
class LessonFactory:
"""Factory: the catalogue import format is data, the lesson types are classes."""
@staticmethod
def create(kind: LessonKind | str, node_id: str, title: str, **options: Any) -> Lesson:
builders = {
LessonKind.VIDEO: lambda: VideoLesson(
node_id, title, int(options.get("duration_minutes", 5)), str(options.get("url", ""))
),
LessonKind.ARTICLE: lambda: ArticleLesson(node_id, title, int(options.get("word_count", 600))),
LessonKind.QUIZ: lambda: Quiz(node_id, title, list(options.get("questions", []))),
LessonKind.ASSIGNMENT: lambda: Assignment(node_id, title, int(options.get("max_points", 100))),
}
try:
return builders[LessonKind(kind)]()
except ValueError:
raise ValidationError(f"unknown lesson kind {kind!r}") from None
Now the Visitors. Three questions, one file, and the tree classes never learned about any of them.
class ContentVisitor(Protocol):
"""One method per node type: the tree is closed, the operations are open."""
def visit_course(self, course: Course) -> object: ...
def visit_module(self, module: Module) -> object: ...
def visit_video(self, lesson: VideoLesson) -> object: ...
def visit_article(self, lesson: ArticleLesson) -> object: ...
def visit_quiz(self, quiz: Quiz) -> object: ...
def visit_assignment(self, assignment: Assignment) -> object: ...
class DurationVisitor:
"""Total minutes. Composites add up their children, leaves report themselves."""
def visit_course(self, course: Course) -> int:
return sum(int(child.accept(self)) for child in course.children())
def visit_module(self, module: Module) -> int:
return sum(int(child.accept(self)) for child in module.children())
def visit_video(self, lesson: VideoLesson) -> int:
return lesson.duration_minutes
def visit_article(self, lesson: ArticleLesson) -> int:
return lesson.duration_minutes
def visit_quiz(self, quiz: Quiz) -> int:
return quiz.time_limit_minutes
def visit_assignment(self, assignment: Assignment) -> int:
return assignment.duration_minutes
class ProgressVisitor:
"""Counts lessons and minutes done against the tree *as it is now*.
Completed ids for lessons that were removed are ignored rather than counted, so
editing a published course can never push anyone over 100 percent.
"""
def __init__(self, completed_lesson_ids: set[str]) -> None:
self._done = completed_lesson_ids
self.completed = 0
self.total = 0
self.minutes_done = 0
self.minutes_total = 0
def result(self) -> Progress:
return Progress(self.completed, self.total, self.minutes_done, self.minutes_total)
def visit_course(self, course: Course) -> Progress:
for child in course.children():
child.accept(self)
return self.result()
def visit_module(self, module: Module) -> Progress:
for child in module.children():
child.accept(self)
return self.result()
def _count(self, lesson: Lesson) -> Progress:
self.total += 1
self.minutes_total += lesson.duration_minutes
if lesson.id in self._done:
self.completed += 1
self.minutes_done += lesson.duration_minutes
return self.result()
def visit_video(self, lesson: VideoLesson) -> Progress:
return self._count(lesson)
def visit_article(self, lesson: ArticleLesson) -> Progress:
return self._count(lesson)
def visit_quiz(self, quiz: Quiz) -> Progress:
return self._count(quiz)
def visit_assignment(self, assignment: Assignment) -> Progress:
return self._count(assignment)
class OutlineVisitor:
"""Renders the syllabus. Proof that a third question needs no change to the tree."""
def __init__(self, completed_lesson_ids: set[str] | None = None) -> None:
self._done = completed_lesson_ids or set()
self._lines: list[str] = []
def text(self) -> str:
return "\n".join(self._lines)
def visit_course(self, course: Course) -> str:
self._lines.append(f"{course.title} [{course.status}]")
for child in course.children():
child.accept(self)
return self.text()
def visit_module(self, module: Module) -> str:
self._lines.append(f" {module.title}")
for child in module.children():
child.accept(self)
return self.text()
def _leaf(self, lesson: Lesson) -> str:
mark = "x" if lesson.id in self._done else " "
self._lines.append(f" [{mark}] {lesson.title} ({lesson.kind}, {lesson.duration_minutes} min)")
return self.text()
def visit_video(self, lesson: VideoLesson) -> str:
return self._leaf(lesson)
def visit_article(self, lesson: ArticleLesson) -> str:
return self._leaf(lesson)
def visit_quiz(self, quiz: Quiz) -> str:
return self._leaf(quiz)
def visit_assignment(self, assignment: Assignment) -> str:
return self._leaf(assignment)
The catalogue is a plain repository with the publish transitions and no permission logic - that belongs to one service, next.
class CourseRepository(Protocol):
"""Repository: the services never touch a dict, so persistence is a swap."""
def add(self, course: Course) -> Course: ...
def get(self, course_id: str) -> Course: ...
def all(self) -> list[Course]: ...
class CourseCatalog:
"""Stores courses and owns the publish/archive transitions. No permission logic."""
def __init__(self) -> None:
self._courses: dict[str, Course] = {}
self._lock = threading.Lock()
def add(self, course: Course) -> Course:
with self._lock:
self._courses[course.id] = course
return course
def get(self, course_id: str) -> Course:
with self._lock:
course = self._courses.get(course_id)
if course is None:
raise ContentNotFoundError(f"unknown course {course_id}")
return course
def all(self) -> list[Course]:
with self._lock:
return list(self._courses.values())
def published(self) -> list[Course]:
return [course for course in self.all() if course.status is PublishStatus.PUBLISHED]
def set_status(self, course_id: str, status: PublishStatus) -> Course:
course = self.get(course_id)
with self._lock:
course.status = status
return course
PermissionService answers the questions; CourseAccessProxy is what guarantees nobody forgets to ask. The check is per call, not per handle.
class EnrollmentLookup(Protocol):
def is_enrolled(self, course_id: str, student_id: str) -> bool: ...
class PermissionService:
"""One place that answers "may this person see or change this course?"."""
def __init__(self, enrollments: EnrollmentLookup | None = None) -> None:
self._enrollments = enrollments
def can_edit(self, viewer: User, course: Course) -> bool:
return viewer.role is Role.ADMIN or viewer.id == course.instructor_id
def can_view(self, viewer: User, course: Course) -> bool:
if self.can_edit(viewer, course):
return True
if course.status is PublishStatus.PUBLISHED:
return True
if course.status is PublishStatus.ARCHIVED and self._enrollments is not None:
return self._enrollments.is_enrolled(course.id, viewer.id)
return False
def can_grade(self, viewer: User, course: Course) -> bool:
return self.can_edit(viewer, course)
def require_view(self, viewer: User, course: Course) -> None:
if self.can_view(viewer, course):
return
if course.status is PublishStatus.DRAFT:
raise NotPublishedError(f"course {course.id} is a draft; {viewer.id} cannot open it")
raise PermissionDeniedError(f"{viewer.id} cannot open course {course.id}")
def require_edit(self, viewer: User, course: Course) -> None:
if not self.can_edit(viewer, course):
raise PermissionDeniedError(f"{viewer.id} cannot edit course {course.id}")
def require_grade(self, viewer: User, course: Course) -> None:
if not self.can_grade(viewer, course):
raise PermissionDeniedError(f"{viewer.id} cannot grade work in course {course.id}")
class CourseAccessProxy(ContentNode):
"""Protection Proxy: the same interface as ``Course``, checked on every access.
The check runs per call, not once in the constructor, because a course can be
archived - or a student unenrolled - between two reads of the same handle.
"""
def __init__(self, course: Course, viewer: User, permissions: PermissionService) -> None:
super().__init__(course.id, course.title)
self._course = course
self._viewer = viewer
self._permissions = permissions
def accept(self, visitor: ContentVisitor) -> Any:
self._guard()
return self._course.accept(visitor)
def children(self) -> list[ContentNode]:
self._guard()
return self._course.children()
def lessons(self) -> Iterator[Lesson]:
self._guard()
return self._course.lessons()
def find(self, node_id: str) -> ContentNode:
self._guard()
return self._course.find(node_id)
@property
def status(self) -> PublishStatus:
return self._course.status
@property
def subject(self) -> Course:
"""The real course, for services that have already checked the rights."""
self._guard()
return self._course
def _guard(self) -> None:
self._permissions.require_view(self._viewer, self._course)
Enrollment is where the interview's concurrency question lives: claim the seat under the course lock, then pay, then activate.
class PaymentGateway(Protocol):
def charge(self, student_id: str, amount: Money) -> bool: ...
class AlwaysApprovesGateway:
def charge(self, student_id: str, amount: Money) -> bool:
return True
class EnrollmentService:
"""Seats, prerequisites and the waiting list. One lock per course.
The seat is claimed *before* the payment and given back if the payment fails, so
two students racing for the last seat cannot both get it and a declined card
cannot leave a seat locked up.
"""
def __init__(
self,
catalog: CourseCatalog,
clock: Clock | None = None,
ids: IdGenerator | None = None,
payments: PaymentGateway | None = None,
observers: Iterable[LearningObserver] = (),
waitlist: bool = True,
) -> None:
self._catalog = catalog
self._clock = clock or SystemClock()
self._ids = ids or SequentialIdGenerator("ENR")
self._payments = payments or AlwaysApprovesGateway()
self._observers = list(observers)
self._waitlist = waitlist
self._enrollments: dict[str, Enrollment] = {}
self._course_locks: dict[str, threading.Lock] = {}
self._registry_lock = threading.Lock()
def enroll(self, course: Course, student: User) -> Enrollment:
if not course.is_open():
raise NotPublishedError(f"course {course.id} is {course.status}; enrollment is closed")
self._require_prerequisites(course, student)
enrollment = self._claim_seat(course, student)
if enrollment.status is EnrollmentStatus.WAITLISTED:
self._announce("waitlisted", [student.id], f"{course.title} is full")
return enrollment
if not course.is_free() and not self._payments.charge(student.id, course.price):
self.cancel(enrollment.id) # the seat goes straight back to the pool
raise PaymentDeclinedError(f"payment of {course.price} declined for {student.id}")
with self._lock_for(course.id):
enrollment.activate()
self._announce("enrolled", [student.id, course.instructor_id], course.title)
return enrollment
def _claim_seat(self, course: Course, student: User) -> Enrollment:
with self._lock_for(course.id):
for existing in self._for_course_unlocked(course.id):
if existing.student_id == student.id and existing.is_live():
raise AlreadyEnrolledError(f"{student.id} is already {existing.status} on {course.id}")
taken = sum(1 for e in self._for_course_unlocked(course.id) if e.takes_a_seat())
if taken >= course.capacity and not self._waitlist:
raise CourseFullError(f"{course.title} is full ({course.capacity} seats)")
status = EnrollmentStatus.PENDING if taken < course.capacity else EnrollmentStatus.WAITLISTED
enrollment = Enrollment(
id=self._ids.next_id(),
course_id=course.id,
student_id=student.id,
status=status,
enrolled_at=self._clock.now(),
price=course.price,
)
self._enrollments[enrollment.id] = enrollment
return enrollment
def cancel(self, enrollment_id: str) -> Enrollment:
"""Free the seat and promote the student who has waited longest."""
enrollment = self.get(enrollment_id)
course = self._catalog.get(enrollment.course_id)
with self._lock_for(course.id):
enrollment.status = EnrollmentStatus.CANCELLED
waiting = sorted(
(e for e in self._for_course_unlocked(course.id) if e.status is EnrollmentStatus.WAITLISTED),
key=lambda e: (e.enrolled_at, e.id),
)
promoted = waiting[0] if waiting else None
if promoted is not None:
promoted.status = (
EnrollmentStatus.ACTIVE if course.is_free() else EnrollmentStatus.PENDING
)
if promoted is not None:
self._announce("promoted from the waiting list", [promoted.student_id], course.title)
return enrollment
def get(self, enrollment_id: str) -> Enrollment:
with self._registry_lock:
enrollment = self._enrollments.get(enrollment_id)
if enrollment is None:
raise ContentNotFoundError(f"unknown enrollment {enrollment_id}")
return enrollment
def for_course(self, course_id: str) -> list[Enrollment]:
with self._registry_lock:
return [e for e in self._enrollments.values() if e.course_id == course_id]
def active(self, course_id: str, student_id: str) -> Enrollment:
for enrollment in self.for_course(course_id):
if enrollment.student_id == student_id and enrollment.status in (
EnrollmentStatus.ACTIVE,
EnrollmentStatus.COMPLETED,
):
return enrollment
raise ContentNotFoundError(f"{student_id} has no active enrollment on {course_id}")
def is_enrolled(self, course_id: str, student_id: str) -> bool:
return any(
e.student_id == student_id and e.is_live() for e in self.for_course(course_id)
)
def counts(self, course_id: str) -> dict[EnrollmentStatus, int]:
counts: dict[EnrollmentStatus, int] = {}
for enrollment in self.for_course(course_id):
counts[enrollment.status] = counts.get(enrollment.status, 0) + 1
return counts
def _require_prerequisites(self, course: Course, student: User) -> None:
for prerequisite in course.prerequisites:
done = any(
e.student_id == student.id and e.status is EnrollmentStatus.COMPLETED
for e in self.for_course(prerequisite)
)
if not done:
raise PrerequisiteError(f"{student.id} has not completed {prerequisite}")
def _for_course_unlocked(self, course_id: str) -> list[Enrollment]:
return [e for e in list(self._enrollments.values()) if e.course_id == course_id]
def _lock_for(self, course_id: str) -> threading.Lock:
with self._registry_lock:
return self._course_locks.setdefault(course_id, threading.Lock())
def _announce(self, event: str, recipients: Sequence[str], detail: str) -> None:
for observer in self._observers: # outside the course lock
observer.on_learning_event(event, recipients, detail)
def seats_left(service: EnrollmentService, course: Course) -> int:
taken = sum(1 for e in service.for_course(course.id) if e.takes_a_seat())
return max(0, course.capacity - taken)
Grading is two strategies with the same signature. ManualGrader.grade returning None is the design, not an omission: it is how one call site handles both kinds of work.
class GradingStrategy(Protocol):
"""Grade the work, or return None to say "a human has to look at this"."""
def grade(self, lesson: Lesson, work: object, at: float) -> Grade | None: ...
class AutoGrader:
"""Quizzes: the answer key is in the questions, so grading is a comparison."""
def grade(self, lesson: Lesson, work: object, at: float) -> Grade:
if not isinstance(lesson, Quiz) or not isinstance(work, QuizAttempt):
raise ValidationError("AutoGrader only grades quiz attempts")
earned = sum(q.points for q in lesson.questions if q.is_correct(work.answers.get(q.id)))
percent = 0.0 if lesson.max_points == 0 else 100 * earned / lesson.max_points
return Grade(
points=earned,
max_points=lesson.max_points,
graded_by="auto",
graded_at=at,
passed=percent >= lesson.pass_mark,
)
class ManualGrader:
"""Assignments: submitting is not finishing. ``grade`` returns None on purpose."""
def grade(self, lesson: Lesson, work: object, at: float) -> Grade | None:
return None
def apply(self, assignment: Assignment, points: int, grader_id: str, at: float) -> Grade:
if not 0 <= points <= assignment.max_points:
raise ValidationError(f"points must be between 0 and {assignment.max_points}")
percent = 100 * points / assignment.max_points if assignment.max_points else 0.0
return Grade(points, assignment.max_points, grader_id, at, percent >= assignment.pass_mark)
def grading_for(lesson: Lesson) -> GradingStrategy:
"""Factory: the lesson type decides who grades it."""
return AutoGrader() if isinstance(lesson, Quiz) else ManualGrader()
Finally the student-facing service. _mark_complete is the one place progress changes, which is why it is also the one place a certificate can be issued.
class LearningService:
"""What a student does after enrolling. One lock per enrollment.
That lock is what makes "the last lesson issues exactly one certificate" true when
two devices finish two lessons at the same moment.
"""
def __init__(
self,
catalog: CourseCatalog,
enrollments: EnrollmentService,
permissions: PermissionService,
clock: Clock | None = None,
ids: IdGenerator | None = None,
observers: Iterable[LearningObserver] = (),
) -> None:
self._catalog = catalog
self._enrollments = enrollments
self._permissions = permissions
self._clock = clock or SystemClock()
self._ids = ids or SequentialIdGenerator("LRN")
self._observers = list(observers)
self._attempts: dict[str, QuizAttempt] = {}
self._submissions: dict[str, Submission] = {}
self._certificates: dict[str, Certificate] = {}
self._locks: dict[str, threading.RLock] = {}
self._registry_lock = threading.Lock()
# -- reading ------------------------------------------------------------------------
def open_course(self, course_id: str, viewer: User) -> CourseAccessProxy:
"""Hand back a Proxy, never the Course itself."""
return CourseAccessProxy(self._catalog.get(course_id), viewer, self._permissions)
def progress(self, course_id: str, student: User) -> Progress:
enrollment = self._enrollments.active(course_id, student.id)
course = self.open_course(course_id, student).subject
return self._progress_of(course, enrollment)
def certificate(self, course_id: str, student_id: str) -> Certificate | None:
with self._registry_lock:
return self._certificates.get(f"{course_id}:{student_id}")
# -- doing --------------------------------------------------------------------------
def complete_lesson(self, course_id: str, student: User, lesson_id: str) -> Progress:
course = self.open_course(course_id, student).subject # permission check
if not isinstance(course.find(lesson_id), Lesson):
raise ContentNotFoundError(f"{lesson_id} is not a lesson")
return self._mark_complete(course, student.id, lesson_id)
def _mark_complete(self, course: Course, student_id: str, lesson_id: str) -> Progress:
"""Record it, recompute, and issue the certificate - all under one lock."""
enrollment = self._enrollments.active(course.id, student_id)
with self._lock_for(enrollment.id):
enrollment.completed_lesson_ids.add(lesson_id)
progress = self._progress_of(course, enrollment)
if progress.is_finished() and enrollment.status is EnrollmentStatus.ACTIVE:
self._finish(course, enrollment, progress)
return progress
def start_attempt(self, course_id: str, student: User, quiz_id: str) -> QuizAttempt:
quiz = self._lesson(course_id, student, quiz_id, Quiz)
self._enrollments.active(course_id, student.id)
attempt = QuizAttempt(self._ids.next_id(), quiz_id, student.id, self._clock.now())
with self._registry_lock:
used = sum(1 for a in self._attempts.values() if a.quiz_id == quiz_id and a.student_id == student.id)
if used >= quiz.max_attempts:
raise AttemptLimitError(f"{student.id} has used all {quiz.max_attempts} attempts on {quiz_id}")
self._attempts[attempt.id] = attempt
return attempt
def submit_attempt(self, course_id: str, student: User, attempt_id: str, answers: dict[str, int]) -> Grade:
attempt = self._attempts[attempt_id]
quiz = self._lesson(course_id, student, attempt.quiz_id, Quiz)
now = self._clock.now()
if now - attempt.started_at > quiz.time_limit_minutes * SECONDS_PER_MINUTE:
attempt.submitted_at = now
raise AttemptWindowError(f"attempt {attempt_id} ran past the {quiz.time_limit_minutes} minute limit")
attempt.answers, attempt.submitted_at = dict(answers), now
grade = AutoGrader().grade(quiz, attempt, now)
attempt.grade = grade
if grade.passed:
self._mark_complete(self._catalog.get(course_id), student.id, quiz.id)
self._announce("quiz graded", [student.id], f"{quiz.title}: {grade}")
return grade
def submit_assignment(self, course_id: str, student: User, assignment_id: str, text: str) -> Submission:
assignment = self._lesson(course_id, student, assignment_id, Assignment)
self._enrollments.active(course_id, student.id)
submission = Submission(self._ids.next_id(), assignment.id, student.id, text, self._clock.now())
with self._registry_lock:
self._submissions[submission.id] = submission
course = self._catalog.get(course_id)
self._announce("assignment submitted", [course.instructor_id], f"{assignment.title} by {student.id}")
return submission
def grade_submission(self, course_id: str, grader: User, submission_id: str, points: int) -> Grade:
course = self._catalog.get(course_id)
self._permissions.require_grade(grader, course) # students cannot grade themselves
submission = self._submissions[submission_id]
assignment = course.find(submission.assignment_id)
if not isinstance(assignment, Assignment):
raise ContentNotFoundError(f"{submission.assignment_id} is not an assignment")
grade = ManualGrader().apply(assignment, points, grader.id, self._clock.now())
submission.grade, submission.status = grade, SubmissionStatus.GRADED
if grade.passed:
self._mark_complete(course, submission.student_id, assignment.id)
self._announce("assignment graded", [submission.student_id], f"{assignment.title}: {grade}")
return grade
# -- internals ----------------------------------------------------------------------
def _lesson(self, course_id: str, viewer: User, lesson_id: str, expected: type[Lesson]) -> Lesson:
node = self.open_course(course_id, viewer).find(lesson_id)
if not isinstance(node, expected):
raise ContentNotFoundError(f"{lesson_id} is not a {expected.__name__.lower()}")
return node
def _progress_of(self, course: Course, enrollment: Enrollment) -> Progress:
return course.accept(ProgressVisitor(set(enrollment.completed_lesson_ids)))
def _finish(self, course: Course, enrollment: Enrollment, progress: Progress) -> None:
enrollment.status = EnrollmentStatus.COMPLETED
enrollment.completed_at = self._clock.now()
certificate = Certificate(
id=self._ids.next_id(),
course_id=course.id,
student_id=enrollment.student_id,
issued_at=enrollment.completed_at,
score_percent=progress.percent,
)
with self._registry_lock:
self._certificates[f"{course.id}:{enrollment.student_id}"] = certificate
self._announce("certificate issued", [enrollment.student_id], course.title)
def _lock_for(self, enrollment_id: str) -> threading.RLock:
with self._registry_lock:
return self._locks.setdefault(enrollment_id, threading.RLock())
def _announce(self, event: str, recipients: Sequence[str], detail: str) -> None:
for observer in self._observers:
observer.on_learning_event(event, recipients, detail)
python -m lld.learning_management.demo runs one course end to end:
C-1 Design patterns in Python: 88 minutes, seats 2
draft is invisible: course C-1 is a draft; ada cannot open it
Ada -> active (seats free: 1)
Linus -> active (seats free: 0)
Marie -> waitlisted (seats free: 0)
Design patterns in Python [published]
Foundations
[ ] Why patterns (video, 12 min)
[ ] SOLID in one page (article, 6 min)
Assessment
[ ] Patterns quiz (quiz, 10 min)
[ ] Refactor a god object (assignment, 60 min)
Ada after two lessons: 2/4 lessons (50.0%), 18/88 min
quiz auto-graded: 3/3 (100.0%) pass; progress 3/4 lessons (75.0%), 28/88 min
peer grading refused: linus cannot grade work in course C-1
instructor grades it: 45/50 (90.0%) pass
Ada is completed: certificate ID-6 at 100.0%
Linus cancels -> Marie moves off the waiting list to pending
10 notifications, Ada has 4
Concurrency and edge cases¶
Which lock protects what.
EnrollmentService._course_locks[course_id]— one per course, held across "count the seats" and "create the enrollment". This is the capacity invariant: without it, twenty threads all read four seats taken and all get in. Per course, so enrolling in two different courses never contends.LearningService._locks[enrollment_id]— one per enrollment, held across "record the lesson, recompute progress, issue the certificate". Two devices finishing the last two lessons at the same moment would otherwise both see 100 percent and issue two certificates.CourseCatalog._lockandNotificationService._lock— the dictionaries themselves.
The ordering rule: an enrollment lock may be taken before a registry lock, never the other way round. Notifications are sent after every lock is released, so a slow email listener cannot hold a course's seats hostage.
The race it prevents. The capacity test enrolls twenty students on a five-seat course from ten threads and asserts five ACTIVE and fifteen WAITLISTED. The certificate test finishes four lessons from four threads and asserts exactly one certificate. Both fail loudly without the locks and pass deterministically with them.
Seat before payment. A paid enrollment goes PENDING under the lock, the gateway is called outside it (a network call must never be made while holding a lock), and a decline cancels the enrollment - which also promotes the first person on the waiting list. Same shape as the ATM's reserve-dispense-commit, and worth naming as such in the room.
Unpublished content. require_view distinguishes "draft" (a NotPublishedError, because the resource is not ready) from "denied" (a PermissionDeniedError). The proxy re-checks per call, so publishing a course makes an existing handle start working and archiving one makes it stop - no cached decisions.
Attempt limits and time windows. The attempt count is read and incremented inside the registry lock, so a student cannot get a fourth attempt by clicking twice. The time window is checked at submission against the injected clock: now - started_at > time_limit, which a FakeClock can drive without a single sleep in the tests.
Progress when the structure changes. Adding a lesson to a published course drops everyone from 100 percent to 80. That is the correct behaviour - the alternative, freezing each student's syllabus at enrollment, is a real product decision (cohorts) and a bigger data model. What must never happen is losing data: completed ids for removed lessons are kept and simply ignored, so restoring the lesson restores the progress. The test asserts both halves.
Cost check. Recomputing progress is one visitor pass over the tree. A large course of 12 modules x 8 lessons is about 100 nodes; at the estimation cheatsheet's 100 ns main-memory reference that is ~10 µs of memory traffic per pass, which is why progress is derived on demand rather than cached in a counter that can drift out of step with the tree.
Common mistake
Storing percent_complete on the enrollment and updating it when a lesson is finished. It looks like an optimisation and it is a bug generator: the instructor adds a lesson and every stored percentage is silently wrong, with no way to tell which are stale. Store the facts (which lessons this student completed), derive the number, and cache it only when a profiler says to - with an invalidation hook on course edits.
Extensibility and follow-ups¶
- Video delivery is the HLD half of this problem: uploads, transcoding, adaptive bitrate and a CDN. The LLD boundary is
VideoLesson.url; everything behind it is Design YouTube or Netflix. - Cohorts and scheduled runs: a
Cohortbetween course and enrollment that pins a syllabus version and a date range. Progress then resolves against the cohort's snapshot, which is the clean answer to "the course changed under me". - Recommendations: a read model over completed courses and prerequisites; the graph is already there in
Course.prerequisites. - A new question type (free text, code with tests): one
Questionsubclass and one grader;AutoGraderbecomes a chain of per-question graders. - Plagiarism checks: a
SubmissionInspectorobserver on the "assignment submitted" event, so the grading path is untouched. - Discussions and moderation:
DiscussionPostexists; add aPermissionService.can_moderateand the same proxy pattern for reading a thread on unpublished content.
Interview tip
When you draw the tree, immediately ask the interviewer: "how often do you add a new node type versus a new question about the tree?" If it is questions, Visitor is right and you say so. If it is node types, Visitor is the wrong trade and a method on the node is better. Naming the trade-off out loud is worth more than the pattern itself - it is the difference between someone who memorised Visitor and someone who knows when it hurts.
Tests¶
tests/test_learning_management.py has 12 cases (15 with parametrisation). They map one-to-one onto the risks: the visitors compute duration and progress on a known tree; a draft is invisible to a student and open to its instructor and an admin; a peer cannot grade a submission; prerequisites block enrollment until the earlier course is completed; a quiz auto-grades and completes itself while an assignment waits for a human; the attempt limit and the time window both fire; editing a published course lowers progress without losing data; a declined payment gives the seat back; the lesson factory builds each kind; and invalid content is refused at construction.
The two worth walking through are the permission check and the capacity race:
def test_a_draft_is_invisible_to_students_but_open_to_its_instructor(clock: FakeClock) -> None:
catalog, _, learning = platform(clock)
course = catalog.add(make_course())
with pytest.raises(NotPublishedError):
learning.open_course(course.id, ADA).children()
assert learning.open_course(course.id, INSTRUCTOR).children() != []
assert learning.open_course(course.id, ADMIN).children() != []
catalog.set_status(course.id, PublishStatus.PUBLISHED)
assert learning.open_course(course.id, ADA).children() != [] # the same handle, new answer
def test_a_full_course_hands_out_exactly_capacity_seats(clock: FakeClock) -> None:
catalog, enrollments, _ = platform(clock)
course = published(catalog, make_course(capacity=5))
students = [User(f"s{i}", f"Student {i}") for i in range(20)]
with ThreadPoolExecutor(max_workers=10) as pool:
statuses = [e.status for e in pool.map(lambda s: enrollments.enroll(course, s), students)]
assert statuses.count(EnrollmentStatus.ACTIVE) == 5 # capacity, never six
assert statuses.count(EnrollmentStatus.WAITLISTED) == 15
with pytest.raises(AlreadyEnrolledError):
enrollments.enroll(course, students[0])
The certificate test is the other concurrency one: four threads finish four lessons and exactly one certificate comes out. Run everything with uv run pytest code/lld/learning_management -q.
45-minute pacing¶
| Minutes | What to do | What to say or write |
|---|---|---|
| 0-5 | Clarify | How deep is the tree? Free or paid? Capacity and prerequisites? Who sees drafts? Out of scope: video delivery, recommendations. |
| 5-10 | Entities | Draw course to module to lesson and name it a Composite. Then Enrollment, Progress, Grade, Certificate. |
| 10-18 | Class diagram | The tree with four leaf types, then the services beside it. Mark the two locks: per course, per enrollment. |
| 18-26 | Visitors | Write accept and DurationVisitor, then ProgressVisitor. Say the trade-off: new questions cheap, new node types expensive. |
| 26-34 | Enrollment | Claim the seat under the course lock, then pay, then activate. Waitlist instead of refusing; promote on cancel. |
| 34-40 | Grading and permissions | AutoGrader versus ManualGrader and the None return. The proxy, and why the check is per call. |
| 40-45 | Extensions | Cohorts for syllabus versioning, video delivery as the HLD hand-off, new question types. |
Related¶
- Composite — the course, module and lesson tree
- Visitor — duration, progress and outline without touching the tree
- Proxy — the permission-checked handle on a course
- Design an in-memory file system — the same Composite plus Visitor pair on a smaller problem
- Design YouTube or Netflix — what sits behind
VideoLesson.urlat scale