Case Study 1: Redesigning GradeBook Pro with OOP
"The first step in fixing a design is seeing that it is broken — not because it does not work, but because it cannot grow."
The Scenario
GradeBook Pro has been one of our running examples since the early chapters. In its procedural form, it works: it stores students, records grades, computes averages, and prints reports. Professor Elena Vasquez has been using the procedural version for a semester, and it handles her class of thirty students without complaint.
But now Dean Thompson has asked if GradeBook Pro can be extended for the entire department. The requirements have expanded:
- Multiple courses, each with its own student roster and grading scheme.
- Different grade calculations — some courses use weighted averages (midterm 30%, final 40%, homework 30%), others use simple averages, and one graduate seminar uses pass/fail.
- Cross-course reports — "Show me all courses where a student's average is below 70."
- Teaching assistants — TAs can view grades and enter homework scores, but cannot modify exam grades.
Elena looks at her procedural code and feels a sinking sensation. The current structure — arrays of records and standalone procedures — was fine for one course. Extending it to handle multiple courses, different grading schemes, and role-based access will require restructuring nearly everything. The procedures assume a single global array of students. The grading logic is hardcoded. There is no concept of "who is allowed to do what."
This is the moment when OOP earns its keep.
The Procedural Version (Before)
Here is a simplified version of the procedural GradeBook:
type
TStudentRecord = record
Name: string;
ID: string;
Grades: array[1..20] of Integer;
GradeCount: Integer;
end;
var
Students: array[1..100] of TStudentRecord;
StudentCount: Integer;
procedure AddStudent(const AName, AID: string);
begin
Inc(StudentCount);
Students[StudentCount].Name := AName;
Students[StudentCount].ID := AID;
Students[StudentCount].GradeCount := 0;
end;
procedure AddGrade(AStudentIndex, AGrade: Integer);
begin
with Students[AStudentIndex] do
begin
Inc(GradeCount);
Grades[GradeCount] := AGrade;
end;
end;
function ComputeAverage(AStudentIndex: Integer): Double;
var
I, Sum: Integer;
begin
Sum := 0;
with Students[AStudentIndex] do
begin
for I := 1 to GradeCount do
Sum := Sum + Grades[I];
if GradeCount > 0 then
Result := Sum / GradeCount
else
Result := 0;
end;
end;
procedure PrintRoster;
var
I: Integer;
begin
for I := 1 to StudentCount do
WriteLn(Students[I].Name, ' (', Students[I].ID, ') — Avg: ',
ComputeAverage(I):0:1);
end;
What Is Wrong with This?
Let us be specific about the problems.
1. Global state. Students and StudentCount are global. There can be only one roster. To support multiple courses, we would need multiple arrays, multiple counts, and every procedure would need an extra parameter to know which roster to operate on.
2. No validation. Nothing prevents AddGrade from adding a grade of -50 or 999. Nothing prevents adding more than 20 grades (array overflow). Nothing prevents adding a student with an empty name.
3. Fixed capacity. The 100-student and 20-grade limits are hardcoded. A course with 101 students breaks the program.
4. No encapsulation. Any code can reach into Students[3].Grades[5] and set it to anything. The data has no protection.
5. Rigid grading. ComputeAverage implements one specific algorithm: simple average. To support weighted averages, we would need a separate function, and every caller would need to know which function to call for which course.
6. No roles. There is no concept of who is performing an operation. A TA and a professor have the same access.
The Design Process
Before writing any code, we ask the fundamental OOP question: What objects exist in this problem domain?
We make a list of nouns from the requirements:
- Student
- Course
- Grade
- GradeBook (the system as a whole)
- Grading Scheme
- Report
- Teaching Assistant / Professor (roles)
Now we ask: for each noun, what data does it have and what behavior does it exhibit?
TStudent
Data: Name, ID, a collection of grades per course.
Behavior: Add a grade, compute average (for a specific course), display summary.
But wait — a student can be in multiple courses, so the grades are not really part of the student. They are part of the enrollment — the relationship between a student and a course. This is an important design insight: sometimes the data does not belong to either object individually but to the association between them.
For simplicity, we will model this as the course holding a list of (student, grades) pairs.
TCourse
Data: Name, code, instructor name, a grading scheme, a collection of enrolled students with their grades.
Behavior: Enroll a student, add a grade for a student, compute a student's final grade (using the course's grading scheme), generate a roster report.
TGradeBook
Data: A collection of courses.
Behavior: Add/remove courses, find all courses for a student, generate cross-course reports.
The Grading Scheme
Different courses use different grading logic. For now, we will represent this as an enumerated type. In Chapter 17, when we learn inheritance, we will refactor this into a class hierarchy.
The OOP Version (After)
{$mode objfpc}{$H+}
uses SysUtils;
type
TGradingScheme = (gsSimpleAverage, gsWeightedAverage, gsPassFail);
TStudent = class
private
FName: string;
FID: string;
public
constructor Create(const AName, AID: string);
destructor Destroy; override;
procedure Display;
property Name: string read FName;
property ID: string read FID;
end;
TEnrollment = class
private
FStudent: TStudent; { Reference, not owned }
FGrades: array of Integer;
FGradeCount: Integer;
FGradeLabels: array of string;
public
constructor Create(AStudent: TStudent);
destructor Destroy; override;
procedure AddGrade(const ALabel: string; AValue: Integer);
function GetGrade(AIndex: Integer): Integer;
function GetGradeLabel(AIndex: Integer): string;
property Student: TStudent read FStudent;
property GradeCount: Integer read FGradeCount;
end;
TCourse = class
private
FName: string;
FCode: string;
FInstructor: string;
FScheme: TGradingScheme;
FEnrollments: array of TEnrollment;
FEnrollmentCount: Integer;
FMidtermWeight: Double;
FFinalWeight: Double;
FHomeworkWeight: Double;
FPassThreshold: Integer;
function FindEnrollment(AStudent: TStudent): TEnrollment;
public
constructor Create(const AName, ACode, AInstructor: string;
AScheme: TGradingScheme);
destructor Destroy; override;
procedure Enroll(AStudent: TStudent);
procedure AddGrade(AStudent: TStudent; const ALabel: string;
AValue: Integer);
function ComputeFinalGrade(AStudent: TStudent): Double;
function IsPassingGrade(AGrade: Double): Boolean;
procedure SetWeights(AMidterm, AFinal, AHomework: Double);
procedure PrintRoster;
property Name: string read FName;
property Code: string read FCode;
property Scheme: TGradingScheme read FScheme;
property EnrollmentCount: Integer read FEnrollmentCount;
end;
TGradeBook = class
private
FCourses: array of TCourse;
FCourseCount: Integer;
FStudents: array of TStudent;
FStudentCount: Integer;
public
constructor Create;
destructor Destroy; override;
function AddStudent(const AName, AID: string): TStudent;
function AddCourse(const AName, ACode, AInstructor: string;
AScheme: TGradingScheme): TCourse;
function FindStudent(const AID: string): TStudent;
function FindCourse(const ACode: string): TCourse;
procedure PrintAtRiskReport(AThreshold: Double);
procedure PrintStudentTranscript(AStudent: TStudent);
property CourseCount: Integer read FCourseCount;
property StudentCount: Integer read FStudentCount;
end;
Key Implementation Details
TStudent: Simple and Focused
constructor TStudent.Create(const AName, AID: string);
begin
inherited Create;
if AName = '' then
raise Exception.Create('Student name cannot be empty');
if AID = '' then
raise Exception.Create('Student ID cannot be empty');
FName := AName;
FID := AID;
end;
procedure TStudent.Display;
begin
WriteLn(FName, ' (', FID, ')');
end;
TStudent is deliberately simple. It holds identity information only. Grade data belongs to the enrollment, not the student. This is a design decision worth noting: in OOP, deciding where data lives is as important as deciding what data exists.
TEnrollment: The Association Object
constructor TEnrollment.Create(AStudent: TStudent);
begin
inherited Create;
FStudent := AStudent; { Store reference — we do NOT own the student }
FGradeCount := 0;
SetLength(FGrades, 10);
SetLength(FGradeLabels, 10);
end;
procedure TEnrollment.AddGrade(const ALabel: string; AValue: Integer);
begin
if (AValue < 0) or (AValue > 100) then
raise Exception.CreateFmt('Invalid grade: %d. Must be 0-100.', [AValue]);
if FGradeCount >= Length(FGrades) then
begin
SetLength(FGrades, Length(FGrades) * 2);
SetLength(FGradeLabels, Length(FGradeLabels) * 2);
end;
FGrades[FGradeCount] := AValue;
FGradeLabels[FGradeCount] := ALabel;
Inc(FGradeCount);
end;
Notice the validation in AddGrade — no grade outside 0-100 can enter the system. Notice the dynamic array growth — no hardcoded limits. And notice the ownership comment: TEnrollment stores a reference to the student but does not own (and therefore does not free) it. The TGradeBook owns all students.
TCourse: Grading Scheme Dispatch
function TCourse.ComputeFinalGrade(AStudent: TStudent): Double;
var
Enrollment: TEnrollment;
I: Integer;
Sum: Double;
MidtermSum, FinalSum, HomeworkSum: Double;
MidtermCount, FinalCount, HomeworkCount: Integer;
begin
Enrollment := FindEnrollment(AStudent);
if Enrollment = nil then
raise Exception.CreateFmt('Student %s is not enrolled in %s',
[AStudent.Name, FName]);
case FScheme of
gsSimpleAverage:
begin
Sum := 0;
for I := 0 to Enrollment.GradeCount - 1 do
Sum := Sum + Enrollment.GetGrade(I);
if Enrollment.GradeCount > 0 then
Result := Sum / Enrollment.GradeCount
else
Result := 0;
end;
gsWeightedAverage:
begin
MidtermSum := 0; MidtermCount := 0;
FinalSum := 0; FinalCount := 0;
HomeworkSum := 0; HomeworkCount := 0;
for I := 0 to Enrollment.GradeCount - 1 do
begin
if Pos('Midterm', Enrollment.GetGradeLabel(I)) > 0 then
begin
MidtermSum := MidtermSum + Enrollment.GetGrade(I);
Inc(MidtermCount);
end
else if Pos('Final', Enrollment.GetGradeLabel(I)) > 0 then
begin
FinalSum := FinalSum + Enrollment.GetGrade(I);
Inc(FinalCount);
end
else
begin
HomeworkSum := HomeworkSum + Enrollment.GetGrade(I);
Inc(HomeworkCount);
end;
end;
Result := 0;
if MidtermCount > 0 then
Result := Result + (MidtermSum / MidtermCount) * FMidtermWeight;
if FinalCount > 0 then
Result := Result + (FinalSum / FinalCount) * FFinalWeight;
if HomeworkCount > 0 then
Result := Result + (HomeworkSum / HomeworkCount) * FHomeworkWeight;
end;
gsPassFail:
begin
Sum := 0;
for I := 0 to Enrollment.GradeCount - 1 do
Sum := Sum + Enrollment.GetGrade(I);
if Enrollment.GradeCount > 0 then
Result := Sum / Enrollment.GradeCount
else
Result := 0;
{ For pass/fail, we still compute a numeric average, }
{ but IsPassingGrade interprets it differently }
end;
end;
end;
The case statement dispatches to different grading algorithms based on the course's scheme. This works for three schemes, but you can already see the strain — adding a fourth scheme means modifying this method. In Chapter 17, we will replace this case with polymorphism, which is far more elegant and extensible.
TGradeBook: Cross-Course Intelligence
procedure TGradeBook.PrintAtRiskReport(AThreshold: Double);
var
I, J: Integer;
Grade: Double;
Found: Boolean;
begin
WriteLn('=== At-Risk Students (Average Below ', AThreshold:0:1, ') ===');
WriteLn;
for I := 0 to FStudentCount - 1 do
begin
Found := False;
for J := 0 to FCourseCount - 1 do
begin
try
Grade := FCourses[J].ComputeFinalGrade(FStudents[I]);
if Grade < AThreshold then
begin
if not Found then
begin
WriteLn(FStudents[I].Name, ' (', FStudents[I].ID, '):');
Found := True;
end;
WriteLn(' ', FCourses[J].Code, ' ', FCourses[J].Name,
': ', Grade:0:1);
end;
except
{ Student not enrolled in this course — skip }
end;
end;
if Found then
WriteLn;
end;
end;
This method iterates across all students and all courses, identifying students who are struggling. In the procedural version, this cross-cutting query would require passing multiple arrays and counts, with tangled parameter lists. In the OOP version, TGradeBook simply asks each course to compute each student's grade.
Ownership Diagram
Understanding who owns whom is critical for correct memory management:
TGradeBook
├── owns TStudent[] (frees all students in destructor)
└── owns TCourse[] (frees all courses in destructor)
└── owns TEnrollment[] (frees all enrollments in destructor)
└── references TStudent (does NOT free — borrowed reference)
The key insight: TEnrollment holds a reference to a TStudent, but it does not own it. If TEnrollment freed the student, and the student was enrolled in multiple courses, we would get a double-free crash. Ownership must be unambiguous: exactly one object is responsible for freeing each resource.
Using the OOP GradeBook
var
GB: TGradeBook;
Alice, Bob, Carol: TStudent;
CS101, Math201: TCourse;
begin
GB := TGradeBook.Create;
try
{ Create students }
Alice := GB.AddStudent('Alice Chen', 'S001');
Bob := GB.AddStudent('Bob Martinez', 'S002');
Carol := GB.AddStudent('Carol Williams', 'S003');
{ Create courses }
CS101 := GB.AddCourse('Intro to CS', 'CS101', 'Dr. Park', gsSimpleAverage);
Math201 := GB.AddCourse('Calculus II', 'MATH201', 'Prof. Liu', gsWeightedAverage);
Math201.SetWeights(0.30, 0.40, 0.30);
{ Enroll and grade }
CS101.Enroll(Alice);
CS101.Enroll(Bob);
CS101.Enroll(Carol);
CS101.AddGrade(Alice, 'HW1', 92);
CS101.AddGrade(Alice, 'HW2', 88);
CS101.AddGrade(Alice, 'Midterm', 85);
CS101.AddGrade(Bob, 'HW1', 55);
CS101.AddGrade(Bob, 'HW2', 60);
CS101.AddGrade(Bob, 'Midterm', 48);
Math201.Enroll(Alice);
Math201.Enroll(Carol);
Math201.AddGrade(Alice, 'Homework 1', 95);
Math201.AddGrade(Alice, 'Midterm Exam', 78);
Math201.AddGrade(Carol, 'Homework 1', 60);
Math201.AddGrade(Carol, 'Midterm Exam', 55);
{ Reports }
CS101.PrintRoster;
WriteLn;
Math201.PrintRoster;
WriteLn;
GB.PrintAtRiskReport(70.0);
finally
GB.Free; { Frees everything: courses, enrollments, students }
end;
end.
One Free call at the top level cascades through the entire object graph. Every resource is cleaned up. No leaks. No dangling references.
Lessons Learned
-
OOP is not about writing less code. The OOP version is longer than the procedural version. The benefit is organization, not brevity. Each class has a clear, focused responsibility.
-
Ownership must be explicit. When objects reference other objects, you must decide who owns (and frees) whom. Getting this wrong causes either memory leaks or double-free crashes.
-
Validation belongs in the class. Grade validation is in
TEnrollment.AddGrade, not in the calling code. The class protects its own invariants. -
The
casestatement inComputeFinalGradeis a design smell. It works, but it violates the Open-Closed Principle: adding a new grading scheme requires modifying existing code. Chapter 17 will show how inheritance and polymorphism eliminate this problem entirely. -
Association objects are sometimes necessary. The relationship between a student and a course has its own data (grades). Modeling this as a separate
TEnrollmentclass is cleaner than forcing the data into eitherTStudentorTCourse.
Discussion Questions
-
What would happen if
TEnrollmentfreed theTStudentin its destructor? Trace through a scenario where Alice is enrolled in two courses to see why this would fail. -
If Dean Thompson later asks for a "audit log" — a record of every grade change with a timestamp and who made the change — which class should be responsible for this? Would you modify an existing class or create a new one?
-
The grading scheme is currently an enumerated type with a
casestatement. Can you envision a design where each grading scheme is its own class, andTCoursesimply callsFScheme.ComputeGrade(Enrollment)? (Hint: this is exactly where we are headed in Chapter 17.)
GradeBook Pro will return in Chapter 17, where inheritance transforms the grading scheme from a case statement into a polymorphic class hierarchy.