One Course, Many Cohorts: Designing Batch-Scoped Content for an LMS
How I decided what should stay shared, what each live batch should own, and how those boundaries shaped the data model.
One Course, Many Cohorts: Designing Batch-Scoped Content for an LMS

e-Institute system design before and after
How I decided what should stay shared, what each live batch should own, and how those boundaries shaped the data model.
When I started looking at batch-specific content for TutorsTap’s e-Institute, the requirement sounded straightforward.
An institution might buy one course, such as Java Programming, and run it several times. Each run could have a different tutor, schedule, start date, and group of students.
We already had a Batch model, so at first glance, this looked like an extension of something the system already understood.
It wasn’t.
The deeper I went into the existing architecture, the more I realized that Batch was really a scheduling and enrollment concept. Almost everything a student actually learned from was still attached to the course itself.
At a high level, the original model looked something like this:

Original TutorsTap e-Institute Model
The batch told us who was teaching and when.
It did not determine which lesson content a student saw, which quizzes belonged to their cohort, which assignments their tutor created, or even the order in which material was taught.
That worked as long as every batch was essentially another scheduled run of the same course.
But that was no longer the product we were trying to build.
Our contracts allowed tutors to own their teaching content. Different cohorts might move at different speeds, use different videos and notes, and have completely different quizzes and assignments.
At the same time, the institute still needed to sell one official course.
That left me with the question that ended up driving the entire redesign:
What is actually shared because it defines the course, and what only makes sense within one batch?
I needed to answer that before deciding where to put batchId.
I started by looking at how other LMSs handle the same problem
This tension is not unique to us, so before designing anything, I looked at patterns used by established learning platforms.
At a high level, I kept seeing a few approaches.
Some platforms organize a master course and then use sections, groups, visibility rules, or activity assignment to create differences between cohorts.
Others lean more heavily on a course template, while assessments or delivery details belong to individual batches.
And another option is the simplest conceptually: duplicate the entire course for each cohort.
That last approach gives every tutor complete independence.
It also creates ten copies of “Java Programming” when an institution runs ten batches.
For us, that was a problem.
The business was selling one program with multiple live runs, not ten unrelated courses that happened to have the same name. Forking the whole tree would also duplicate maintenance and work against the structure we already had:
Course
└── Batch
└── EnrolledCourse
I wasn’t trying to reproduce Canvas, Moodle, Blackboard, or any other LMS inside our application. I was trying to understand which established patterns survived contact with our constraints.
Those constraints were unusually important here:
- Tutors own their teaching content.
- The institute owns the branded course.
- Different batches may use different numbers of quizzes and assignments.
- Tutors should be able to change pacing.
- Certificates still need an institute-level meaning.
- Our existing application already treats batches as children of a course.
No single pattern fit all of those requirements without additional work.
So I stopped thinking in terms of “which LMS model should we use?” and started breaking the product into scopes.
The turning point was defining ownership before schema
The most useful question became:
Who owns this piece of information, and at what scope is it true?
That produced a much cleaner boundary.
The institute owns the identity of the course.
A tutor owns how their batch experiences it.
For example, the name of a module such as Object-Oriented Programming is part of the official syllabus. That should not silently become something completely different because one tutor renamed it.
But the video that Tutor Alice uses to teach that module does not need to be the same video Tutor Bob uses.
The same distinction applied to order.
The institute can define a suggested sequence, but different cohorts move differently. I brought that question to the team, and we decided that tutors should be able to reorder modules and lessons for their own batch.
That did not mean changing the shared index values for everyone.
It meant keeping the institute order as the default and treating a tutor’s order as a batch-scoped override.
The resulting ownership model looked roughly like this:
ConcernScopeOwnerModule and lesson namesCourseInstituteDefault teaching orderCourseInstituteBatch teaching orderBatch overrideTutorLesson videos, files, notesBatchTutorTutor quizzes and assignmentsBatchTutorInstitute finalCourseInstituteFinal exam unlockBatchInstitute/Admin
That table resolved questions that had previously sounded vague, like:
“Can tutors edit the curriculum?”
The answer was no longer simply yes or no.
They could change delivery, but not redefine the shared curriculum structure.
That distinction eventually shaped the data model more than any individual table did.
The old quiz model was where the abstraction visibly broke
Assessments exposed the problem better than anything else.
In the original system, quizzes were partly represented as curriculum structure.
An admin could create a Lesson with:
type = Quiz
That lesson acted as a quiz slot inside the shared curriculum tree.
Quiz content was then linked to that slot.
There was also application logic enforcing one quiz per lesson.
For a single shared course experience, that made sense.
For multiple independently taught batches, it did not.
Imagine the shared Java Programming curriculum contains five quiz slots:
Module 1 Check
Module 2 Check
Module 3 Check
Module 4 Check
Module 5 Check
Tutor Alice teaches Batch A and creates her quiz for Module 2 Check.
Then Tutor Bob teaches Batch B and tries to create different questions for the same slot.
The existing service checks whether any quiz is already attached to that lesson:
const existingQuiz = await prisma.quiz.findFirst({
where: {
lessonIds: {
some: { id: { in: resolvedLessonIds } },
},
},
});
if (existingQuiz) {
return {
error:
"One or more selected lessons already have a quiz. Each lesson can only have one quiz.",
};
}
There is no batch dimension in that check.
So the system is not asking:
Does Bob’s batch already have a quiz for this slot?
It is effectively asking:
Does anyone have a quiz for this slot?
Once Alice fills it, Bob cannot independently fill it.
That was the first obvious failure.
But the more important realization came next.
I could have changed the uniqueness logic to something like (lessonId, batchId) and allowed both tutors to attach quizzes to the same slot.
That would have fixed the collision.
It would not have fixed the model.
Suppose the institute creates four shared quiz slots.
Batch A’s tutor wants four quizzes.
Batch B’s tutor only wants two.
Even with batch-specific quiz content, Batch B still inherits four places in the curriculum where quizzes are expected to exist. Two become empty, hidden, or awkward placeholders.
Now reverse it.
If the institute creates only two slots and Batch A’s tutor later wants a third or fourth quiz, that tutor cannot add one without changing the shared curriculum structure for every batch.
The shared slot model was quietly assuming something the product had never actually promised:
Every cohort should have the same assessment structure.
And we did not want that.
The institute needed consistency in the syllabus, not in the number of practice quizzes a tutor chose to give.
That led to the decision to move tutor-created assessments out of the shared curriculum tree.
A tutor quiz became something owned by a batch.
So did assignments.
The student experience could now separate two concepts:
Curriculum
→ lessons
Assessments
→ tutor quizzes
→ tutor assignments
→ institute final
This also solved a more operational problem.
If a tutor reaches week eight and realizes their cohort needs an extra Arrays deep-dive check, that should be an ordinary teaching decision.
Under the shared-slot model, the tutor would need an administrator to change the course-wide curriculum so that one batch could get another quiz.
Under the batch-owned model, the tutor simply adds another assessment to their batch.
That was the point where I stopped thinking of quizzes as curriculum slots and started thinking of them as activities with their own ownership and scope.
Then certificates pushed the design in the opposite direction
At this point, it would have been easy to conclude:
Everything tutors touch should become batch-scoped.
Certificates showed why that would have been too simplistic.
The original certificate logic was already problematic in a multi-batch system.
It queried quizzes attached to the course:
const courseQuizzes = await prisma.quiz.findMany({
where: {
courseIds: {
some: { id: Number(courseId) },
},
},
});
That means a Batch A student could potentially be evaluated against quizzes created for other tutors and other batches.
That clearly needed to change.
My first design, which I called Option A, was straightforward:
A student could receive the certificate after their batch ended and after passing every assessment their tutor had marked requiredForCertificate.
It fit the new architecture well.
If Alice required four quizzes and Bob required two, each student’s certificate checklist would only include assessments belonging to their own batch.
Technically, it was clean.
It also respected tutor ownership.
And it fixed the cross-batch bug.
But it introduced a different problem.
Alice and Bob were teaching the same official course, and their students would receive the same Java Programming certificate.
Under Option A, one tutor might require six assessments while another required two. One might mark everything as required. Another might treat every quiz as practice.
The batches would be internally consistent, but the institute would no longer control what its own credential meant.
That was not just an engineering question anymore, so I brought it to the team.
We decided on a narrower model.
Tutors would continue to own their quizzes and assignments.
But one institute-owned final exam per course would serve as the certificate requirement in v1.
That distinction ended up being important:
We did not reject batch-scoped teaching. We rejected batch-scoped certification.
Tutor Alice might give four quizzes.
Tutor Bob might give seven.
That is part of how they teach.
But both cohorts eventually face the same institute final if they want the same institute-issued credential.
That gave us a shared certification bar without forcing tutors to standardize all of their day-to-day assessments.
Even a shared final still had batch-scoped state
The certificate decision led to another scope question.
If the final exam belongs to the course, should its unlock date also belong to the course?
No.
Batches do not run on the same calendar.
Batch A might be ready for the final in September. Batch B might not be ready until October.
A global unlockAt field on the course-level quiz would therefore encode the wrong real-world fact.
The exam itself is shared.
Its availability is not.
So I separated them:
Institute Final
scope: Course
QuizBatchUnlock
scope: Final + Batch
That let the institute keep one final while controlling when each cohort could take it.
Certificate issuance also remained separate from exam access.
A student might take the final before the batch officially ends, but the certificate should still only be issued after batch.endDate.
Those are two different questions:
Can this student take the exam?
and:
Can this student receive the credential?
They happen to involve the same final, but they are not the same state transition.
This was one of those places where putting everything on the quiz row would have looked simpler while actually making the domain model less accurate.
The resulting pattern was not “add batchId everywhere”
Once these decisions were clear, the technical shape became much easier to reason about.
Different kinds of batch variation needed different modeling strategies.
For lesson content, the lesson itself stays shared while its delivery varies:
Lesson
→ shared title and structure
LessonBatchContent
→ lessonId
→ batchId
→ video
→ notes
→ files
For order, the shared value becomes a default and the batch stores an override:
Course order
→ default
Batch order
→ optional overlay
For tutor assessments, the entity itself belongs to the batch:
Tutor Quiz
→ batchId
Assignment
→ batchId
For the institute final, the entity stays shared while one piece of runtime state belongs to the batch:
Institute Final
→ course-scoped
Unlock
→ batch-scoped
These are three different patterns:
- Shared object + batch-specific content
- Shared default + batch override
- Batch-owned entity
And in the case of the final, a fourth variation:
Shared entity + batch-specific state
That was much more precise than making every table batch-specific.
It also kept us from accidentally destroying the idea of a shared course while trying to give tutors independence.
The redesign also changed what enrollment meant
There was one more uncomfortable consequence.
Originally, EnrolledCourse.batchId could be optional because batch membership did not determine much of what a student saw.
Once content, assessments, order, unlocks, and certificate logic became batch-aware, that was no longer harmless.
If a course has batches, the system needs to know which one a student belongs to.
Otherwise every downstream question becomes ambiguous:
- Which lesson content should they see?
- Which tutor assessments belong to them?
- Which order should their curriculum use?
- Is their institute final unlocked?
- Has their batch ended?
- Which certificate rules apply?
Once Batch became a real content boundary, enrollment had to treat it that way too.
The part I found most useful was the question, not the schema
Looking back, the biggest shift was not adding LessonBatchContent, QuizBatchUnlock, or batchId to more places.
It was changing the question I was asking.
At first, the problem looked like:
How do I make this existing LMS support batch-specific content?
The more useful question became:
At what scope is each fact actually true?
A module name is true for the course.
A tutor’s lesson video is true for a batch.
The institute order is a course default.
A tutor’s reordered sequence is a batch override.
A tutor quiz belongs to one batch.
The institute final belongs to the course.
The date that the final becomes available belongs to the batch.
Whether one student can receive a certificate depends on their own enrollment, their batch, and the institute’s certification rule.
Once I had those boundaries, the schema stopped feeling like a collection of exceptions.
It started reflecting the product.
That is probably the biggest lesson I took from the redesign:
Before deciding where a field belongs, decide who owns the fact and where that fact is true.
Sometimes the right answer is a shared model.
Sometimes it is a scoped override.
Sometimes it is a completely batch-owned entity.
And sometimes, as the certificate discussion taught me, the technically cleanest scoped solution still needs to be reconsidered because the product is promising something bigger than the data model alone can tell you.
메타데이터
- post_id
- 91eace5b667b
- slug
- one-course-many-cohorts-designing-batch-scoped-content-for-an-lms-91eace5b667b
- url
- https://medium.com/@almee6198/one-course-many-cohorts-designing-batch-scoped-content-for-an-lms-91eace5b667b
- canonical_url
- https://medium.com/@almee6198/one-course-many-cohorts-designing-batch-scoped-content-for-an-lms-91eace5b667b
- author_url
- https://medium.com/@almee6198
- status
- ok
- fetched_at
- 2026-08-16 08:01:07