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:

  1. Multiple courses, each with its own student roster and grading scheme.
  2. 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.
  3. Cross-course reports — "Show me all courses where a student's average is below 70."
  4. 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

  1. 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.

  2. 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.

  3. Validation belongs in the class. Grade validation is in TEnrollment.AddGrade, not in the calling code. The class protects its own invariants.

  4. The case statement in ComputeFinalGrade is 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.

  5. Association objects are sometimes necessary. The relationship between a student and a course has its own data (grades). Modeling this as a separate TEnrollment class is cleaner than forcing the data into either TStudent or TCourse.


Discussion Questions

  1. What would happen if TEnrollment freed the TStudent in its destructor? Trace through a scenario where Alice is enrolled in two courses to see why this would fail.

  2. 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?

  3. The grading scheme is currently an enumerated type with a case statement. Can you envision a design where each grading scheme is its own class, and TCourse simply calls FScheme.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.