Course Registration
Prerequisite validation and capacity-based enrollment with pluggable waitlist strategies (FIFO vs priority). Schedule conflict detection prevents overlapping time slots before enrollment commits.
Key Abstractions
Orchestrator that coordinates enrollment, prerequisite checks, capacity limits, and waitlist management
Academic offering with a capacity cap, prerequisites list, schedule, and enrolled student roster
Enrollee with a set of completed courses, current enrollments, and a schedule
Record linking a student to a course with status tracking: ENROLLED, WAITLISTED, DROPPED
Day-and-time interval; overlap check prevents students from enrolling in two concurrent courses
Strategy for ordering waitlisted students: FIFO (first-come) or priority-based (GPA, seniority)
Class Diagram
The Key Insight
Course registration is really three separate validation problems running in sequence: does this student qualify, does this student have time, and is there space? Most naive implementations jump straight to capacity. But a student who has not passed Calculus I has no business in Calculus II, even with 50 empty seats. And a student already in a Monday 9-10am class cannot take another Monday 9-10am class, no matter the prerequisites or capacity.
Run the checks in order. Prerequisites first, because there is no point checking anything else if the student does not qualify. Schedule conflicts second, because even a qualified student cannot physically be in two rooms at once. Capacity last, because that is where the waitlist decision lives. This ordering produces clear error messages and avoids wasted work.
The waitlist itself is a strategy problem. FIFO is the default and works fine for most cases. But some departments want seniors first. Others want the highest GPA. Making the waitlist strategy pluggable means the registrar changes policy without touching enrollment logic. One constructor argument swap.
Requirements
Functional
- Students can enroll in courses if they meet prerequisites and have no schedule conflicts
- Courses have a maximum capacity. When full, additional students join a waitlist.
- When a student drops a course, the next waitlisted student is automatically promoted
- Support multiple waitlist strategies: FIFO (first come first served) and priority-based (by GPA)
- Detect and reject time slot conflicts before enrollment commits
- Validate all prerequisite courses have been completed before allowing enrollment
Non-Functional
- Prerequisite checks, schedule conflict detection, and capacity checks should all be O(n) or better
- Adding a new waitlist strategy requires no changes to the RegistrationSystem class
- Observer notifications for enrollment events support multiple consumers (email, dashboard, audit log)
- Enrollment is idempotent: enrolling twice returns the existing status, not a duplicate
Design Decisions
Why check prerequisites before capacity?
If you check capacity first and the course is full, you add the student to the waitlist. Later when a seat opens, the system tries to promote them and discovers they are missing a prerequisite. Wasted waitlist slot. Confused student who waited for nothing. Checking prerequisites first catches this immediately.
Why Strategy for the waitlist?
A hardcoded FIFO queue works until the registrar says seniors should get priority. Now you rewrite the promotion logic inside the Course class. Then they want GPA-based priority for honors sections. Another rewrite. Strategy pattern lets each policy live in its own class. The Course calls getNext() on whatever strategy is configured. Swapping policies is a constructor argument.
Why Observer for enrollment events?
Multiple systems react to enrollment changes: the student portal updates the schedule, the billing system charges tuition, the department dashboard refreshes counts, and an audit log records everything. Without observer, the enroll method needs explicit calls to each of these. Adding a new consumer means modifying enrollment logic. With observer, consumers register themselves and get notified automatically.
Why is TimeSlot a separate value object?
Schedule conflict detection is its own concern with its own logic. Embedding time overlap checks inside Course or Student mixes scheduling with domain logic. A dedicated TimeSlot with overlapsWith() makes conflict detection testable on its own and reusable for exam scheduling, room booking, or office hours.
Interview Follow-ups
- "How would you handle concurrent enrollment?" Optimistic locking with a version field on Course. Two students enrolling simultaneously both read version 1, but only one write of version 2 succeeds. The other retries against the updated state.
- "How would you add course sections?" Each section is a separate Course instance sharing prerequisites but with different schedules and capacities. Students pick a specific section. The system groups sections under a parent for catalog display.
- "How would you implement swap enrollment?" Atomic operation: drop course A and enroll in course B as a single transaction. If enrollment in B fails for any reason, the drop of A rolls back. This prevents students from losing their seat when switching sections.
- "How would you add enrollment periods?" Introduce an
EnrollmentWindowwith start/end timestamps and eligible student tiers (seniors register first, then juniors, etc.). The RegistrationSystem checks the current time and the student's year before processing any request.
Code Implementation
1 from enum import Enum
2 from abc import ABC, abstractmethod
3 from dataclasses import dataclass, field
4 from typing import Optional
5 import time
6
7
8 class EnrollmentStatus(Enum):
9 ENROLLED = "enrolled"
10 WAITLISTED = "waitlisted"
11 DROPPED = "dropped"
12
13
14 @dataclass
15 class EnrollmentResult:
16 success: bool
17 status: Optional[EnrollmentStatus]
18 message: str
19
20
21 @dataclass
22 class TimeSlot:
23 day_of_week: str
24 start_hour: int
25 end_hour: int
26
27 def overlaps_with(self, other: "TimeSlot") -> bool:
28 if self.day_of_week != other.day_of_week:
29 return False
30 return self.start_hour < other.end_hour and other.start_hour < self.end_hour
31
32 def __str__(self) -> str:
33 return f"{self.day_of_week} {self.start_hour}:00-{self.end_hour}:00"
34
35
36 @dataclass
37 class Enrollment:
38 student_id: str
39 course_id: str
40 status: EnrollmentStatus
41 timestamp: float = field(default_factory=time.time)
42
43
44 # --- Waitlist strategies ---
45
46 class WaitlistStrategy(ABC):
47 @abstractmethod
48 def add_to_waitlist(self, waitlist: list[str], student_id: str,
49 students: dict[str, "Student"]) -> None: ...
50
51 @abstractmethod
52 def get_next(self, waitlist: list[str],
53 students: dict[str, "Student"]) -> Optional[str]: ...
54
55
56 class FifoWaitlistStrategy(WaitlistStrategy):
57 """First come, first served. Simple append to the end."""
58
59 def add_to_waitlist(self, waitlist: list[str], student_id: str,
60 students: dict[str, "Student"]) -> None:
61 waitlist.append(student_id)
62
63 def get_next(self, waitlist: list[str],
64 students: dict[str, "Student"]) -> Optional[str]:
65 return waitlist.pop(0) if waitlist else None
66
67
68 class PriorityWaitlistStrategy(WaitlistStrategy):
69 """Higher GPA gets promoted first."""
70
71 def add_to_waitlist(self, waitlist: list[str], student_id: str,
72 students: dict[str, "Student"]) -> None:
73 waitlist.append(student_id)
74
75 def get_next(self, waitlist: list[str],
76 students: dict[str, "Student"]) -> Optional[str]:
77 if not waitlist:
78 return None
79 best = max(waitlist, key=lambda sid: students[sid].gpa)
80 waitlist.remove(best)
81 return best
82
83
84 # --- Observer ---
85
86 class RegistrationObserver(ABC):
87 @abstractmethod
88 def on_enrollment(self, student_id: str, course_id: str) -> None: ...
89
90 @abstractmethod
91 def on_drop(self, student_id: str, course_id: str) -> None: ...
92
93 @abstractmethod
94 def on_waitlist_promotion(self, student_id: str, course_id: str) -> None: ...
95
96
97 class ConsoleRegistrationObserver(RegistrationObserver):
98 def __init__(self, name: str):
99 self._name = name
100
101 def on_enrollment(self, student_id: str, course_id: str) -> None:
102 print(f" [{self._name}] {student_id} enrolled in {course_id}")
103
104 def on_drop(self, student_id: str, course_id: str) -> None:
105 print(f" [{self._name}] {student_id} dropped {course_id}")
106
107 def on_waitlist_promotion(self, student_id: str, course_id: str) -> None:
108 print(f" [{self._name}] {student_id} promoted from waitlist for {course_id}")
109
110
111 # --- Core domain ---
112
113 class Student:
114 def __init__(self, student_id: str, name: str, gpa: float = 0.0):
115 self.id = student_id
116 self.name = name
117 self.gpa = gpa
118 self.completed_courses: set[str] = set()
119 self.current_enrollments: dict[str, Enrollment] = {}
120
121 def has_completed(self, course_id: str) -> bool:
122 return course_id in self.completed_courses
123
124 def get_schedule(self, courses: dict[str, "Course"]) -> list[TimeSlot]:
125 slots = []
126 for cid, enrollment in self.current_enrollments.items():
127 if enrollment.status == EnrollmentStatus.ENROLLED and cid in courses:
128 slots.append(courses[cid].schedule)
129 return slots
130
131 def has_conflict(self, slot: TimeSlot, courses: dict[str, "Course"]) -> bool:
132 for existing in self.get_schedule(courses):
133 if existing.overlaps_with(slot):
134 return True
135 return False
136
137 def __str__(self) -> str:
138 return f"{self.name} (GPA: {self.gpa})"
139
140
141 class Course:
142 def __init__(self, course_id: str, name: str, capacity: int,
143 schedule: TimeSlot, prerequisites: list[str] = None,
144 waitlist_strategy: WaitlistStrategy = None):
145 self.id = course_id
146 self.name = name
147 self.capacity = capacity
148 self.schedule = schedule
149 self.prerequisites = prerequisites or []
150 self._enrolled: list[str] = []
151 self._waitlist: list[str] = []
152 self._waitlist_strategy = waitlist_strategy or FifoWaitlistStrategy()
153
154 def has_capacity(self) -> bool:
155 return len(self._enrolled) < self.capacity
156
157 def is_full(self) -> bool:
158 return not self.has_capacity()
159
160 @property
161 def enrolled_count(self) -> int:
162 return len(self._enrolled)
163
164 @property
165 def waitlist_count(self) -> int:
166 return len(self._waitlist)
167
168 def add_student(self, student_id: str) -> None:
169 if student_id not in self._enrolled:
170 self._enrolled.append(student_id)
171
172 def remove_student(self, student_id: str) -> bool:
173 if student_id in self._enrolled:
174 self._enrolled.remove(student_id)
175 return True
176 if student_id in self._waitlist:
177 self._waitlist.remove(student_id)
178 return True
179 return False
180
181 def add_to_waitlist(self, student_id: str,
182 students: dict[str, Student]) -> None:
183 self._waitlist_strategy.add_to_waitlist(
184 self._waitlist, student_id, students
185 )
186
187 def promote_from_waitlist(self, students: dict[str, Student]) -> Optional[str]:
188 return self._waitlist_strategy.get_next(self._waitlist, students)
189
190 def __str__(self) -> str:
191 return (f"{self.name} ({self.enrolled_count}/{self.capacity}, "
192 f"waitlist: {self.waitlist_count})")
193
194
195 class RegistrationSystem:
196 def __init__(self):
197 self._courses: dict[str, Course] = {}
198 self._students: dict[str, Student] = {}
199 self._enrollments: list[Enrollment] = []
200 self._observers: list[RegistrationObserver] = []
201
202 def add_observer(self, observer: RegistrationObserver) -> None:
203 self._observers.append(observer)
204
205 def register_course(self, course: Course) -> None:
206 self._courses[course.id] = course
207
208 def register_student(self, student: Student) -> None:
209 self._students[student.id] = student
210
211 def _check_prerequisites(self, student: Student, course: Course) -> Optional[str]:
212 for prereq in course.prerequisites:
213 if not student.has_completed(prereq):
214 return f"Missing prerequisite: {prereq}"
215 return None
216
217 def _check_schedule_conflict(self, student: Student,
218 course: Course) -> Optional[str]:
219 if student.has_conflict(course.schedule, self._courses):
220 return f"Schedule conflict with {course.schedule}"
221 return None
222
223 def enroll(self, student_id: str, course_id: str) -> EnrollmentResult:
224 student = self._students.get(student_id)
225 if not student:
226 return EnrollmentResult(False, None, "Student not found")
227
228 course = self._courses.get(course_id)
229 if not course:
230 return EnrollmentResult(False, None, "Course not found")
231
232 # Already enrolled or waitlisted?
233 if course_id in student.current_enrollments:
234 existing = student.current_enrollments[course_id]
235 if existing.status != EnrollmentStatus.DROPPED:
236 return EnrollmentResult(False, existing.status,
237 "Already enrolled or waitlisted")
238
239 # Prerequisites first
240 prereq_error = self._check_prerequisites(student, course)
241 if prereq_error:
242 return EnrollmentResult(False, None, prereq_error)
243
244 # Schedule conflicts second
245 conflict_error = self._check_schedule_conflict(student, course)
246 if conflict_error:
247 return EnrollmentResult(False, None, conflict_error)
248
249 # Capacity check and enrollment
250 if course.has_capacity():
251 course.add_student(student_id)
252 enrollment = Enrollment(student_id, course_id, EnrollmentStatus.ENROLLED)
253 student.current_enrollments[course_id] = enrollment
254 self._enrollments.append(enrollment)
255 for obs in self._observers:
256 obs.on_enrollment(student_id, course_id)
257 return EnrollmentResult(True, EnrollmentStatus.ENROLLED,
258 f"Enrolled in {course.name}")
259
260 # Course full, add to waitlist
261 course.add_to_waitlist(student_id, self._students)
262 enrollment = Enrollment(student_id, course_id, EnrollmentStatus.WAITLISTED)
263 student.current_enrollments[course_id] = enrollment
264 self._enrollments.append(enrollment)
265 return EnrollmentResult(True, EnrollmentStatus.WAITLISTED,
266 f"Added to waitlist for {course.name} "
267 f"(position {course.waitlist_count})")
268
269 def drop(self, student_id: str, course_id: str) -> bool:
270 student = self._students.get(student_id)
271 course = self._courses.get(course_id)
272 if not student or not course:
273 return False
274
275 if course_id not in student.current_enrollments:
276 return False
277
278 old_enrollment = student.current_enrollments[course_id]
279 was_enrolled = old_enrollment.status == EnrollmentStatus.ENROLLED
280
281 course.remove_student(student_id)
282 old_enrollment.status = EnrollmentStatus.DROPPED
283 for obs in self._observers:
284 obs.on_drop(student_id, course_id)
285
286 # Auto-promote the next waitlisted student if a seat opened
287 if was_enrolled:
288 promoted_id = course.promote_from_waitlist(self._students)
289 if promoted_id:
290 promoted_student = self._students[promoted_id]
291 course.add_student(promoted_id)
292 promoted_student.current_enrollments[course_id].status = (
293 EnrollmentStatus.ENROLLED
294 )
295 for obs in self._observers:
296 obs.on_waitlist_promotion(promoted_id, course_id)
297
298 return True
299
300 def get_course_info(self, course_id: str) -> str:
301 course = self._courses.get(course_id)
302 if not course:
303 return "Course not found"
304 return str(course)
305
306
307 if __name__ == "__main__":
308 system = RegistrationSystem()
309 system.add_observer(ConsoleRegistrationObserver("Registrar"))
310
311 # Create courses
312 calc1 = Course("MATH101", "Calculus I", capacity=2,
313 schedule=TimeSlot("Monday", 9, 10))
314 calc2 = Course("MATH201", "Calculus II", capacity=2,
315 schedule=TimeSlot("Monday", 10, 11),
316 prerequisites=["MATH101"])
317 physics = Course("PHYS101", "Physics I", capacity=2,
318 schedule=TimeSlot("Monday", 9, 10))
319 cs_intro = Course("CS101", "Intro to CS", capacity=2,
320 schedule=TimeSlot("Tuesday", 14, 16))
321
322 for c in [calc1, calc2, physics, cs_intro]:
323 system.register_course(c)
324
325 # Create students
326 alice = Student("S001", "Alice", gpa=3.8)
327 alice.completed_courses.add("MATH101")
328 bob = Student("S002", "Bob", gpa=3.2)
329 bob.completed_courses.add("MATH101")
330 charlie = Student("S003", "Charlie", gpa=3.9)
331 dave = Student("S004", "Dave", gpa=2.5)
332
333 for s in [alice, bob, charlie, dave]:
334 system.register_student(s)
335
336 # Test 1: Basic enrollment
337 print("=== Basic Enrollment ===")
338 result = system.enroll("S001", "MATH101")
339 print(f" Alice -> Calc I: {result.message}")
340
341 result = system.enroll("S002", "MATH101")
342 print(f" Bob -> Calc I: {result.message}")
343 print(f" {system.get_course_info('MATH101')}")
344 print()
345
346 # Test 2: Prerequisite check
347 print("=== Prerequisite Validation ===")
348 result = system.enroll("S003", "MATH201")
349 print(f" Charlie -> Calc II (no prereq): {result.message}")
350
351 result = system.enroll("S001", "MATH201")
352 print(f" Alice -> Calc II (has prereq): {result.message}")
353 print()
354
355 # Test 3: Schedule conflict
356 print("=== Schedule Conflict Detection ===")
357 result = system.enroll("S001", "PHYS101")
358 print(f" Alice -> Physics (conflicts with Calc I): {result.message}")
359 print()
360
361 # Test 4: Capacity and FIFO waitlist
362 print("=== Capacity and Waitlist ===")
363 result = system.enroll("S003", "MATH101")
364 print(f" Charlie -> Calc I (should waitlist): {result.message}")
365 print(f" {system.get_course_info('MATH101')}")
366 print()
367
368 # Test 5: Drop and auto-promote from waitlist
369 print("=== Drop and Auto-Promotion ===")
370 system.drop("S001", "MATH101")
371 print(f" After Alice drops: {system.get_course_info('MATH101')}")
372 print()
373
374 # Test 6: Priority waitlist strategy (GPA-based)
375 print("=== Priority Waitlist (GPA-based) ===")
376 cs_priority = Course("CS201", "Data Structures", capacity=1,
377 schedule=TimeSlot("Wednesday", 10, 12),
378 waitlist_strategy=PriorityWaitlistStrategy())
379 system.register_course(cs_priority)
380
381 system.enroll("S001", "CS201")
382 print(f" Alice enrolled: {system.get_course_info('CS201')}")
383
384 system.enroll("S004", "CS201")
385 print(f" Dave waitlisted (GPA {dave.gpa}): {system.get_course_info('CS201')}")
386
387 system.enroll("S003", "CS201")
388 print(f" Charlie waitlisted (GPA {charlie.gpa}): {system.get_course_info('CS201')}")
389
390 # Drop Alice. Charlie (3.9 GPA) should be promoted over Dave (2.5 GPA)
391 print("\n Dropping Alice from CS201...")
392 system.drop("S001", "CS201")
393 print(f" After drop: {system.get_course_info('CS201')}")
394
395 print("\nAll assertions passed.")Common Mistakes
- ✗Checking capacity but not prerequisites. A student who has not passed Calc I should never get a seat in Calc II regardless of availability.
- ✗Letting students enroll in time-conflicting courses. If two courses overlap on Tuesday 10-11am, the second enrollment must be rejected.
- ✗Hardcoding FIFO waitlist logic. When the registrar wants priority-based promotion (seniors first), you have to rewrite the waitlist.
- ✗Forgetting to notify waitlisted students on drop. Without observer, seats freed by drops sit empty until someone manually checks.
Key Points
- ✓Prerequisite validation runs before capacity checks. No point reserving a seat if the student is not qualified.
- ✓Schedule conflict detection happens before enrollment commits. Two courses at the same time slot are rejected upfront.
- ✓Waitlist is strategy-driven. FIFO is default, but swapping to priority-based (by GPA or seniority) is a single strategy swap.
- ✓Observer notifies waitlisted students when a seat opens up from a drop. The system auto-promotes the next eligible student.