Skip to content

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, Module and Lesson; Lesson has VideoLesson, ArticleLesson, Quiz and Assignment. LessonFactory builds a leaf from imported data.
  • ContentVisitor with DurationVisitor, ProgressVisitor and OutlineVisitor — 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) and ManualGrader (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.

code/lld/learning_management/models.py — enums
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"
code/lld/learning_management/models.py — errors
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.

code/lld/learning_management/models.py — entities
@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.

code/lld/learning_management/content.py — the composite
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.

code/lld/learning_management/content.py — lessons and the factory
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.

code/lld/learning_management/visitors.py
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.

code/lld/learning_management/services.py — catalogue
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.

code/lld/learning_management/services.py — permissions and the proxy
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.

code/lld/learning_management/services.py — enrollment
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.

code/lld/learning_management/learning.py — grading
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.

code/lld/learning_management/learning.py — the learning service
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.

  1. 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.
  2. 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.
  3. CourseCatalog._lock and NotificationService._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 Cohort between 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 Question subclass and one grader; AutoGrader becomes a chain of per-question graders.
  • Plagiarism checks: a SubmissionInspector observer on the "assignment submitted" event, so the grading path is untouched.
  • Discussions and moderation: DiscussionPost exists; add a PermissionService.can_moderate and 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:

code/lld/learning_management/tests/test_learning_management.py — drafts
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
code/lld/learning_management/tests/test_learning_management.py — twenty students, five seats
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.