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.
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
└─ QuestionEach 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.
Related articles
- Cart, Checkout and Access Entitlements for Course Platforms
How to separate orders, payments and access in a course platform — bundles, idempotent payment confirmation, and a single entitlement check that decides who sees what.
- Modeling Data with Prisma and MongoDB: Embedding vs Referencing
How to design MongoDB collections when you use Prisma — when to embed, when to reference, how relations work, and the constraints that come with Prisma's MongoDB connector.