Engineering Atlas › Low-Level Design

Code an online course platform

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope

Entities and relationships

Course is an identity: an id, an owner and a list of versions. It has no lessons of its own. CourseVersion is an immutable snapshot: a version number, an ordered list of Lesson objects, the prerequisite course ids, and the quiz pass mark. Lessons are identified by stable ids that survive across versions, so "lesson intro" in version 1 and version 2 is the same lesson for progress purposes.

Lesson has an id, a title and a kind. VideoLesson knows its duration; QuizLesson knows its questions. They differ in what "complete" means: a video is complete when watched, a quiz when the best score reaches the pass mark. That difference is behaviour, so it lives in the lesson type, not in if kind == "quiz" checks across the codebase.

Enrollment joins a learner to a course and pins a version. It holds the completed lesson ids, each quiz's best score, and an optional Certificate. Certificate is a value object: an id, the learner, the course, the version completed and the issue time. Once issued it never changes.

LearningPlatform is the facade: publish, enroll, record a completion, upgrade, and query progress and certificates. It takes an injected Clock for certificate timestamps.

Class diagram

classDiagram
    class Course {
        +id
        +versions list~CourseVersion~
        +latest() CourseVersion
    }
    class CourseVersion {
        +number int
        +lessons list~Lesson~
        +prereqs list
        +passMark int
    }
    class Lesson {
        <<abstract>>
        +id
        +isComplete(enrollment, passMark) bool
    }
    class VideoLesson {
        +durationSec int
    }
    class QuizLesson {
        +questions list
    }
    class Enrollment {
        +learner
        +version int
        +done set
        +bestScores map
        +progress() int
    }
    class Certificate {
        +id
        +version int
        +issuedAt int
    }
    class LearningPlatform {
        +publish(courseId, lessons, prereqs, passMark) int
        +enroll(learner, courseId) int
        +complete(learner, courseId, lessonId, score) bool
        +upgrade(learner, courseId) int
        +certificate(learner, courseId) Certificate
    }
    Course "1" *-- "*" CourseVersion
    CourseVersion "1" *-- "*" Lesson
    Lesson <|-- VideoLesson
    Lesson <|-- QuizLesson
    Enrollment --> CourseVersion
    Enrollment "1" o-- "0..1" Certificate
    LearningPlatform --> Course
    LearningPlatform --> Enrollment

Key flows

The flow that ties the design together is the last lesson. Mia has finished two of three lessons and submits the final quiz. The facade records the best score, checks the pass mark, recomputes progress, and issues the certificate in the same step, so no reader can see 100% without a certificate.

sequenceDiagram
    participant M as Mia
    participant P as LearningPlatform
    participant E as Enrollment
    participant V as CourseVersion
    participant C as Clock
    M->>P: complete(mia, sql101, final-quiz, 85)
    P->>E: find(mia, sql101)
    E-->>P: pinned version 1
    P->>V: lesson(final-quiz)
    V-->>P: quiz, pass mark 70
    P->>E: best = max(best, 85), mark done
    P->>E: progress()
    E-->>P: 100
    P->>C: now()
    C-->>P: 1700000000
    P->>E: attach Certificate(CERT-7, v1, 1700000000)
    P-->>M: true

If the same request is retried after a timeout, the best score stays 85, the lesson is already done, and a certificate already exists, so nothing changes. Idempotency comes from the shape of the state (a set and a max), not from a deduplication table.

Design patterns used and why

Immutable snapshot (versioning). Each publish produces a new CourseVersion and never edits an old one. Enrollments point at a version, so an instructor deleting a lesson cannot move a learner from 100% to 90% or invalidate the version their certificate names. This is the same move as pinning a dependency lock file.

Polymorphism for lesson kinds. Lesson.isComplete(enrollment, passMark) is overridden by VideoLesson and QuizLesson. Adding a CodingExercise that is complete when hidden tests pass is a new subclass; progress code does not change.

Composite for structure. Real courses nest: sections contain lessons, and a "specialisation" contains courses. A Composite lets progress be computed the same way at every level (fraction of leaves complete), and lets a weighted rule replace the plain count later.

Observer for side effects. LessonCompleted and CertificateIssued events drive emails, streaks, analytics and recommendations. These subscribers should never be able to make recording a completion fail.

Facade with an injected clock. One method per learner action, and time read through clock(), so tests can assert exact certificate timestamps.

Concurrency and edge cases

Duplicate and out-of-order completion events

A video player posts "completed" at the end, again on replay, and again when a phone that was offline reconnects. Store completions as a set keyed by (learner, course, lesson), or in a database as a unique key with INSERT ... ON CONFLICT DO NOTHING. Quiz scores fold with max. Both operations are commutative and idempotent, so arrival order and repeats do not matter. The xAPI standard bakes the same idea into its protocol: each statement carries a client-chosen UUID, and a learning record store that receives an id it already has should check the content matches and answer 409 Conflict if it does not.

Two final lessons finishing at once

On two devices, Mia completes the last video and the last quiz within a millisecond. Each request sees progress at 100% and tries to issue a certificate. Under a per-enrollment lock (or a row lock on the enrollment), the second request sees the certificate already attached and does nothing. Without it, she gets two certificates with different ids, which is exactly what a verification page cannot tolerate.

Prerequisite cycles

Course A requires B. Later, B version 2 adds A as a prerequisite. Nobody can now enroll in either. On publish, run a depth-first search from each new prerequisite along the latest versions' edges. If it reaches the course being published, reject the publish. The graph has one node per course, so this is cheap.

Upgrading a learner

Upgrading maps completions by stable lesson id. A lesson that disappeared is dropped, and a new lesson starts incomplete. A quiz carried over is re-checked against the new pass mark, because a version can raise it. The certificate, if any, is untouched: it names the version it was earned on.

Which version's prerequisites?

Enrollment checks the latest version's prerequisites, because that is what the learner is enrolling in. Changing prerequisites never removes existing enrollments.

Down the stack

Layer 1The contract

For each enrollment, progress = floor(100 * |done ∩ lessons(v)| / |lessons(v)|) where v is the pinned version. Recording the same completion again changes nothing. A quiz is done exactly when its best score is at least the version's pass mark. A certificate is issued at most once per enrollment, at the first moment progress reaches 100, and is never modified. The prerequisite graph over latest versions is acyclic. A rejected call changes nothing.

Layer 2The mechanism

A course stores versions in a list indexed by number, so pinning is just an integer. An enrollment keeps a set of done lesson ids and a map of best scores. The complete method is a set insert and a max, and progress is a count over the pinned version's lesson list. Publishing validates the lessons (non-empty, unique ids, known kinds), then runs a DFS over prerequisite edges, which costs O(courses + edges) in the worst case. At scale, completions are an append-only event table (learner, course, lesson, score, ts, event_id), and the enrollment row holds the denormalised done set and percentage, rebuilt from events if ever in doubt. That is event sourcing in its most ordinary form.

Layer 3The machine

A 200-lesson course fits its done set into a 200-bit bitmap: 25 bytes, four 64-bit words. With lesson positions assigned per version, progress is four POPCNT instructions, which modern x86 cores retire at about one per cycle, compared with hashing 200 strings. A million active enrollments then cost 25 MB of bitmaps, which fits in one cache server's memory. The catch is that positions are per version, so upgrade must remap bits through the lesson ids, which is one more reason upgrades are explicit. The event table grows by roughly 100 bytes per row. Ten million completions a day is about 1 GB a day, cheap to keep forever and enough to rebuild any learner's state.

Why it's built this way

Instructors fix and reorder content constantly, but learners and employers need progress and certificates that do not shift, so content is versioned and enrollments pin a version. Networks and players retry, so completion is recorded as set membership and best score, both naturally idempotent. A certificate is a promise to third parties, so it is immutable and issued exactly once under a lock. Prerequisite edges come from many instructors who cannot see each other's courses, so the platform, not the authors, guarantees there is no cycle.

Extensibility

Leadership lens

Check yourself

  1. An instructor removes a lesson from a course that Mia is halfway through. What happens to her progress?

    Show answer

    Nothing. The edit is a new version and Mia stays pinned to hers. If she upgrades, her completions are mapped by lesson id, the removed lesson drops out, and her percentage is recomputed over the new version.

  2. Why keep the best quiz score rather than the latest?

    Show answer

    So that completion is monotonic and idempotent: a later poor attempt or a replayed older event can never undo a pass, and repeated submissions in any order produce the same state.

  3. Two devices finish Mia's last two lessons simultaneously. What guarantees one certificate?

    Show answer

    Issuance happens in the same critical section as the completion, under a per-enrollment lock or row lock. The second request sees the attached certificate and does nothing.

  4. Course A already requires B. Why is it not enough to check only that B exists when A is published?

    Show answer

    Existence does not prevent B from later adding A as its own prerequisite. Each publish must check that none of its new prerequisites can reach the course itself.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →