Skip to main content
Published / updated

Classes and Practical Object-Oriented Programming

Before you start

You need: methods and parameters (Article 03).

Time: about 60 minutes, plus the practice. This is the longest article so far and the one the rest of the track builds on.

Learning objective

Model the School Management System entities as classes, protect their data with encapsulation, and use inheritance, interfaces and polymorphism where they genuinely help.

Topics

  • Classes and objects
  • Constructors
  • Properties and access modifiers
  • Encapsulation — protecting invalid states
  • Static members
  • Inheritance
  • Polymorphism — virtual, override, abstract
  • Interfaces and abstraction
  • Composition over inheritance

Classes and objects

A class is a blueprint. An object is one thing built from it.

public class Student
{
public int Id { get; set; }
public Guid PublicId { get; set; }
public int SchoolId { get; set; }
public string Name { get; set; }
public string RollNumber { get; set; }
public string ClassName { get; set; }
public string Section { get; set; }
public DateTime DateOfBirth { get; set; }
public string ParentName { get; set; }
public string ParentPhone { get; set; }
public StudentStatus Status { get; set; }
}
Student ravi = new Student();
ravi.Name = "Ravi Kumar";
ravi.RollNumber = "NCA-2024-0012";
ravi.ClassName = "10th";
ravi.Section = "A";
ravi.Status = StudentStatus.Active;

Student priya = new Student();
priya.Name = "Priya Sharma";
priya.RollNumber = "NCA-2024-0013";

One Student class; 800 Student objects, each with its own values.

Model what the business actually has. Student, Teacher, FeeAccount, ExamResult are things the school talks about. A class named DataManager or Helper usually means the modelling was skipped.

Object initialiser syntax is shorter and equivalent:

Student arjun = new Student
{
Name = "Arjun Reddy",
RollNumber = "NCA-2024-0014",
ClassName = "9th",
Section = "B",
Status = StudentStatus.Active
};

Constructors

A constructor runs when the object is created, and it is where you enforce that an object cannot exist in an invalid state.

public class FeeAccount
{
public int Id { get; set; }
public int SchoolId { get; set; }
public int StudentId { get; set; }
public string AcademicYear { get; set; }
public decimal TotalFees { get; set; }
public decimal PaidAmount { get; set; }
public decimal DiscountAmount { get; set; }
public DateTime DueDate { get; set; }

public FeeAccount(int schoolId, int studentId, string academicYear, decimal totalFees, DateTime dueDate)
{
if (totalFees < 0)
{
throw new ArgumentException("Total fees cannot be negative.", nameof(totalFees));
}

if (string.IsNullOrWhiteSpace(academicYear))
{
throw new ArgumentException("Academic year is required.", nameof(academicYear));
}

SchoolId = schoolId;
StudentId = studentId;
AcademicYear = academicYear;
TotalFees = totalFees;
DueDate = dueDate;
PaidAmount = 0m;
DiscountAmount = 0m;
}
}

Validating in the constructor means a FeeAccount with negative fees cannot be created at all. Validating afterwards means it can exist, briefly, and be used by anything that runs in between.

A class with no constructor gets a parameterless one automatically. Write one yourself and that free one disappears — which breaks code doing new FeeAccount(). If both are needed, declare both.

Constructors can chain:

public FeeAccount(int schoolId, int studentId, string academicYear)
: this(schoolId, studentId, academicYear, 0m, DateTime.UtcNow.AddMonths(3))
{
}

Properties and access modifiers

public class Teacher
{
private decimal _salary;

public int Id { get; set; }
public string Name { get; set; }
public string EmployeeCode { get; set; }
public string Qualification { get; set; }
public int ExperienceYears { get; private set; }

public decimal Salary
{
get
{
return _salary;
}
set
{
if (value < 0)
{
throw new ArgumentException("Salary cannot be negative.");
}

_salary = value;
}
}
}
FormMeaning
{ get; set; }Auto-property — read and write
{ get; private set; }Read anywhere, write only inside the class
{ get; }Set once, in the constructor
Full get/set bodyValidation or computation on access
ModifierVisible to
publicEverything
privateOnly this class — the default for fields
protectedThis class and classes inheriting from it
internalThis assembly

Start every member private and open it only when something outside needs it. A public field can be set to anything by anyone, from anywhere, and when a wrong value appears you have no place to put a breakpoint.

A computed property has no stored value:

public decimal Balance
{
get
{
return TotalFees - DiscountAmount - PaidAmount;
}
}

Compute rather than store what is derivable. Storing Balance means keeping it in step with three other fields forever, and the day someone updates PaidAmount without it, the balance is wrong.

Encapsulation

Encapsulation is not "make fields private". It is "make invalid states unreachable".

public class FeeAccount
{
public decimal TotalFees { get; private set; }
public decimal PaidAmount { get; private set; }
public decimal DiscountAmount { get; private set; }

public decimal Balance
{
get
{
return TotalFees - DiscountAmount - PaidAmount;
}
}

public void RecordPayment(decimal amount)
{
if (amount <= 0)
{
throw new ArgumentException("Payment amount must be greater than zero.");
}

if (amount > Balance)
{
throw new InvalidOperationException("Payment exceeds the outstanding balance.");
}

PaidAmount = PaidAmount + amount;
}

public void ApplyDiscount(decimal amount)
{
if (amount < 0 || amount > TotalFees)
{
throw new ArgumentException("Discount must be between zero and the total fees.");
}

DiscountAmount = amount;
}
}

With PaidAmount set only through RecordPayment, there is exactly one place a payment can be recorded — so there is one place to validate it, one place to log it, and one place to fix it.

account.PaidAmount = 999999m; // will not compile
account.RecordPayment(12000m); // validated

Compare with a class of public setters: forty call sites can each write a different wrong value, and finding which one did means reading all forty.

Static members

public class Student
{
public static int TotalStudentCount { get; private set; }

public const decimal MaximumMarks = 100m;

public Student()
{
TotalStudentCount = TotalStudentCount + 1;
}

public static string BuildRollNumber(string schoolCode, int year, int sequence)
{
return $"{schoolCode}-{year}-{sequence:D4}";
}
}
string roll = Student.BuildRollNumber("NCA", 2024, 12); // NCA-2024-0012
int count = Student.TotalStudentCount;

Static belongs to the class; instance belongs to the object. BuildRollNumber does not need a particular student to do its job, so it is static.

Static state is shared by everything in the process. A static List<Student> used as storage is one list for the whole application — occasionally useful, usually a bug waiting for the second user.

Use static for stateless helpers and genuine constants. Avoid it for anything that holds changing data.

Inheritance

public class Person
{
public int Id { get; set; }
public int SchoolId { get; set; }
public string Name { get; set; }
public DateTime DateOfBirth { get; set; }

public int GetAge()
{
int age = DateTime.UtcNow.Year - DateOfBirth.Year;

if (DateTime.UtcNow.DayOfYear < DateOfBirth.DayOfYear)
{
age = age - 1;
}

return age;
}
}

public class Student : Person
{
public string RollNumber { get; set; }
public string ClassName { get; set; }
public string Section { get; set; }
public StudentStatus Status { get; set; }
}

public class Teacher : Person
{
public string EmployeeCode { get; set; }
public string Qualification { get; set; }
public decimal Salary { get; set; }
}

Student and Teacher each get Id, SchoolId, Name, DateOfBirth and GetAge() without repeating them.

Inherit only for a genuine "is a" relationship. A Student is a Person; a Student is not a FeeAccount, even though they are related.

Inheritance is the tightest coupling in object-oriented code. Change Person and every descendant changes. Two levels is usually plenty; five is a sign the model is wrong.

Polymorphism

One call, different behaviour depending on the actual object.

public abstract class SchoolMember
{
public int Id { get; set; }
public string Name { get; set; }

public abstract string GetRoleDescription();

public virtual string GetDisplayName()
{
return Name;
}
}

public class Student : SchoolMember
{
public string RollNumber { get; set; }

public override string GetRoleDescription()
{
return "Student";
}

public override string GetDisplayName()
{
return $"{Name} ({RollNumber})";
}
}

public class Teacher : SchoolMember
{
public string EmployeeCode { get; set; }

public override string GetRoleDescription()
{
return "Teaching staff";
}
}
List<SchoolMember> members = new List<SchoolMember>();
members.Add(new Student { Name = "Ravi Kumar", RollNumber = "NCA-2024-0012" });
members.Add(new Teacher { Name = "Dr. Mehta", EmployeeCode = "NCA-T-004" });

foreach (SchoolMember member in members)
{
Console.WriteLine($"{member.GetDisplayName()}{member.GetRoleDescription()}");
}
Ravi Kumar (NCA-2024-0012) — Student
Dr. Mehta — Teaching staff

The loop does not check what type each member is. Each object supplies its own behaviour. Adding a Staff class later requires no change to this loop — which is the practical payoff.

KeywordMeaning
abstract (class)Cannot be instantiated; exists to be inherited
abstract (method)No body; every child must override it
virtualHas a default body; a child may override it
overrideReplaces the parent's implementation
sealedNo further inheritance or overriding

abstract when there is no sensible default. virtual when there is. GetRoleDescription has no default — a SchoolMember with no role is meaningless. GetDisplayName has one.

Interfaces and abstraction

An interface is a contract. It says what a class can do, never how.

This is abstraction — the fourth pillar of object-oriented programming, alongside encapsulation, inheritance and polymorphism.

Abstraction means depending on what something does, not how it does it. You already rely on this every day: you call students.Add(ravi) without knowing how List<T> grows its internal array. The what is all you need; the how is free to change.

In the example below, StudentService needs "something that can fetch a student". It does not need to know whether that something reads SQL Server or an in-memory list — so it depends on the interface, and either implementation slots in. That is abstraction doing real work: it is what lets you test the service with no database at all.

An abstract class abstracts shared behaviour; an interface abstracts the contract alone.

public interface IStudentRepository
{
Student GetById(int schoolId, int studentId);
Student GetByRollNumber(int schoolId, string rollNumber);
List<Student> GetByClass(int schoolId, string className, string section);
void Add(Student student);
void Update(Student student);
}
public class SqlStudentRepository : IStudentRepository
{
public Student GetById(int schoolId, int studentId)
{
// reads from SQL Server
return null;
}

// ... the rest
}

public class InMemoryStudentRepository : IStudentRepository
{
private readonly List<Student> _students = new List<Student>();

public Student GetById(int schoolId, int studentId)
{
foreach (Student student in _students)
{
if (student.SchoolId == schoolId && student.Id == studentId)
{
return student;
}
}

return null;
}

// ... the rest
}
public class StudentService
{
private readonly IStudentRepository _repository;

public StudentService(IStudentRepository repository)
{
_repository = repository;
}

public Student FindStudent(int schoolId, string rollNumber)
{
return _repository.GetByRollNumber(schoolId, rollNumber);
}
}

StudentService depends on the interface, not on SQL Server. So it can be tested against the in-memory version with no database at all, and swapping the data layer touches one line of setup.

Abstract classInterface
Inherit how manyOneMany
Can hold stateYesNot really
Shares implementationYesOnly defaults
Says"is a""can do"

A class inherits one base and implements many interfaces. Interface names begin with I by .NET convention.

Composition over inheritance

// Forced inheritance
public class FeeReportingStudent : Student
{
public decimal CalculateBalance() { return 0m; }
}

// Composition — preferred
public class FeeService
{
private readonly IFeeRepository _feeRepository;

public FeeService(IFeeRepository feeRepository)
{
_feeRepository = feeRepository;
}

public decimal CalculateBalance(int schoolId, int studentId)
{
FeeAccount account = _feeRepository.GetByStudentId(schoolId, studentId);

if (account == null)
{
return 0m;
}

return account.TotalFees - account.DiscountAmount - account.PaidAmount;
}
}

"Has a" is more flexible than "is a". A service that holds a repository can be given a different one; a class that inherits a base is welded to it.

Default to composition. Reach for inheritance when the "is a" relationship is real and the shared behaviour is substantial.

Errors you will hit

MessageCauseFix
CS1729: 'FeeAccount' does not contain a constructor that takes 0 argumentsYou wrote a constructor, so the free parameterless one disappearedAdd a parameterless constructor too, or pass the arguments
CS0122: 'FeeAccount.PaidAmount' is inaccessible due to its protection levelTried to set a private set property from outsideCall the method that changes it
CS0200: Property or indexer cannot be assigned to — it is read onlyProperty has no setterSet it in the constructor
CS0534: does not implement inherited abstract memberA child class skipped an abstract methodImplement it, or make the class abstract
CS0535: does not implement interface memberClass claims an interface it does not fulfilAdd the missing members
System.NullReferenceException on a new object's propertyThe object was created but the property never assignedAssign it in the constructor

CS1729 surprises everyone. Adding your first constructor silently removes the free one, and every new Student() elsewhere stops compiling.

Common mistakes

  • Public fields instead of properties
  • Validating after construction instead of inside the constructor
  • Adding a constructor and breaking existing new Student() calls
  • Storing a value that could be computed
  • Inheriting for code reuse rather than an "is a" relationship
  • Deep inheritance chains
  • Static collections used as application storage
  • abstract where virtual was meant, or the reverse
  • Classes named Manager, Helper or Utility doing several unrelated jobs
  • Depending on a concrete class where an interface would allow testing

Practice

  1. Write the Student class with every field from the School Management System schema.
  2. Create three students — Ravi Kumar, Priya Sharma, Arjun Reddy — using object initialisers.
  3. Write a FeeAccount constructor that rejects negative fees and an empty academic year. Prove both throw.
  4. Add a constructor to a class that had none, then confirm new FeeAccount() no longer compiles.
  5. Make PaidAmount private set and add RecordPayment. Try to assign the property directly.
  6. Add validation to RecordPayment that rejects a payment above the balance. Test both paths.
  7. Replace a stored Balance field with a computed property, then verify it after a payment.
  8. Add a Salary property to Teacher with a full set body that rejects negatives.
  9. Write Student.BuildRollNumber as a static method producing NCA-2024-0012.
  10. Add a static TotalStudentCount and confirm it is shared across every instance.
  11. Build PersonStudent and Teacher, and call GetAge() from both.
  12. Write abstract SchoolMember with an abstract GetRoleDescription and a virtual GetDisplayName. Override both in Student, one in Teacher.
  13. Put both into one List<SchoolMember> and loop without checking types.
  14. Add a Staff class and confirm the loop needs no change.
  15. Define IStudentRepository, implement it twice — SQL and in-memory — and swap them behind StudentService.
  16. Rewrite an inheritance-based design as composition and compare which is easier to change.

Exercises 5 to 7 are the encapsulation core. Exercises 13 and 14 are what makes polymorphism click.

You can now

  • Model a School entity as a class with the right properties
  • Validate in the constructor so an invalid object cannot exist
  • Use private set and a method to control how a value changes
  • Say what encapsulation actually protects
  • Choose between inheritance, an interface, and composition
  • Explain when abstract is right and when virtual is
  • Put two different types in one list and call the same method on both

Review questions

  1. Why validate in the constructor rather than after creating the object?
  2. What does private set on PaidAmount buy you that a public setter does not?
  3. When is abstract right and when is virtual right?
  4. Why does StudentService depend on IStudentRepository rather than SqlStudentRepository?

Next: Collections and generics