Skip to content
PAPRADEEPADHIKARI
LMSMongoDBSystem Design

Designing an LMS Data Model: Courses, Lessons, Quizzes and Progress

A practical data model for a learning platform — content hierarchy, free previews, quiz attempts, past-paper practice and progress tracking — with the trade-offs behind each choice.

Pradeep Adhikari
· 5 min read

A learning management system looks simple on a whiteboard: courses contain lessons, students watch them. The difficulty is that the same data has to serve very different readers — a student browsing a syllabus, an editor reordering chapters, a dashboard computing progress, a payment flow deciding what is unlocked. A model that fits only the first reader causes pain for the rest.

This article lays out a model for an exam-preparation platform in which a subject is taught through lessons and quizzes and reinforced with past papers. It uses generic entity names; the ideas apply to any structured-course product, including the kind described in the E2A Learning case study.

The content hierarchy

Start with the structure a student sees:

Subject
  └─ Chapter
       ├─ Lesson (video, notes)
       └─ Quiz
            └─ Question

Each level is its own collection, with a reference to its parent and an explicit order field:

model Subject {
  id       String    @id @default(auto()) @map("_id") @db.ObjectId
  slug     String    @unique
  title    String
  chapters Chapter[]
}

model Chapter {
  id        String   @id @default(auto()) @map("_id") @db.ObjectId
  subjectId String   @db.ObjectId
  subject   Subject  @relation(fields: [subjectId], references: [id])
  title     String
  order     Int
  lessons   Lesson[]
  quizzes   Quiz[]

  @@index([subjectId, order])
}

model Lesson {
  id         String  @id @default(auto()) @map("_id") @db.ObjectId
  chapterId  String  @db.ObjectId
  chapter    Chapter @relation(fields: [chapterId], references: [id])
  title      String
  order      Int
  videoKey   String?
  freePreview Boolean @default(false)

  @@index([chapterId, order])
}

Two decisions deserve explanation.

Explicit `order` over array position. Editors reorder content. If order is implied by position in an embedded array, every reorder rewrites the parent document and risks concurrent-edit conflicts. A numeric order on each child lets you change one lesson without touching its siblings. Leave gaps (10, 20, 30) or renumber in a transaction.

Reference, don't embed, the levels. Lessons and quizzes are edited independently, referenced from progress records, and can grow large. They also need to be fetched independently — a lesson page does not need the whole subject. The hierarchy is shallow enough that a few indexed queries load a syllabus cheaply.

Free previews belong on the content

To let visitors sample a subject, mark content as previewable rather than special-casing it in access code. A boolean such as freePreview on lessons — or on chapters, if you want to open whole chapters — lets editors control the funnel without a deploy. The access check then becomes one question: is this content free, or does this user hold an entitlement that covers it? That check is the subject of Cart, Checkout and Access Entitlements.

Quizzes: separate the definition from the attempt

A quiz definition is content. An attempt is user data. Keeping them apart prevents the most common LMS bug: editing a question retroactively changes a student's recorded result.

model Question {
  id          String   @id @default(auto()) @map("_id") @db.ObjectId
  quizId      String   @db.ObjectId
  prompt      String
  options     Option[]
  explanation String?
  order       Int

  @@index([quizId, order])
}

type Option {
  key       String
  text      String
  isCorrect Boolean
}

model QuizAttempt {
  id          String   @id @default(auto()) @map("_id") @db.ObjectId
  userId      String   @db.ObjectId
  quizId      String   @db.ObjectId
  startedAt   DateTime @default(now())
  submittedAt DateTime?
  score       Int?
  total       Int?
  responses   Response[]

  @@index([userId, quizId, startedAt])
}

type Response {
  questionId String
  chosenKey  String
  correct    Boolean
}

Options are embedded in the question because they are always read together and bounded. Responses are embedded in the attempt for the same reason. The attempt records correct at submission time and stores the score, so a later edit to the question does not rewrite history. If you need to reconstruct exactly what the student saw, snapshot the prompt text into the response too.

Grade on the server. Never trust the client to report correctness. Send answers up, compare against the stored options on the server, and send back the result with the explanation. Do not include isCorrect in the payload that delivers the question to the client before submission.

export async function submitAttempt(userId: string, quizId: string, answers: Record<string, string>) {
  const questions = await prisma.question.findMany({ where: { quizId } });
  const responses = questions.map((q) => {
    const chosenKey = answers[q.id];
    const correct = q.options.some((o) => o.key === chosenKey && o.isCorrect);
    return { questionId: q.id, chosenKey, correct };
  });
  const score = responses.filter((r) => r.correct).length;
  return prisma.quizAttempt.create({
    data: { userId, quizId, submittedAt: new Date(), score, total: questions.length, responses },
  });
}

Past papers as a parallel content tree

Past-paper practice has a different shape from lessons: papers are organised by exam session, component and question number rather than by chapter. Model them as their own tree — paper, then question — and connect them to the syllabus through tags or a mapping table (a question belongs to a topic or chapter). That keeps both trees clean and lets you offer "practice this chapter" without duplicating content.

Progress: store events, compute summaries

Progress is the most-read and most-misdesigned part of an LMS. Resist the urge to store a single "percent complete" number. Store small facts and derive the rest.

model LessonProgress {
  id          String    @id @default(auto()) @map("_id") @db.ObjectId
  userId      String    @db.ObjectId
  lessonId    String    @db.ObjectId
  positionSec Int       @default(0)
  completed   Boolean   @default(false)
  completedAt DateTime?
  updatedAt   DateTime  @updatedAt

  @@unique([userId, lessonId])
  @@index([userId, updatedAt])
}

One row per user and lesson, enforced by the compound unique index, makes writes idempotent: a video player can send the playback position every few seconds as an upsert without creating duplicates.

Chapter and subject progress are then aggregations — completed lessons divided by total lessons, best quiz score per chapter — computed on read or cached per user and subject when reads dominate. Cache values explicitly, treat them as disposable, and be able to rebuild them from the underlying records. Because content changes (a lesson is added to a chapter), a stored percentage becomes wrong the moment an editor publishes; a derived one stays right.

Draft and published content

Editors need to work without students seeing half-finished material. A status field (draft, published) on content, with every student-facing query filtering on published, is the simplest approach. Centralise that filter in the data-access functions, so no route can forget it. A single missed filter is how unpublished content leaks.

Indexes that matter

  • (parentId, order) on every child collection.
  • (userId, entityId) unique on progress records.
  • (userId, startedAt) on attempts for history views.
  • Slugs, unique, on anything with a public URL.

Summary

  • Model the hierarchy with references and explicit ordering.
  • Put preview rules on content, not in access code.
  • Keep quiz definitions and attempts separate; grade on the server.
  • Treat past papers as a parallel tree linked by topic.
  • Store progress as small idempotent facts and derive summaries.
  • Filter on publication status in one place.

The storage rules behind these choices are in Modeling Data with Prisma and MongoDB. Once content is modelled, the next question is who may see it, which is where access entitlements and checkout come in.