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;
}
}
}
| Form | Meaning |
|---|---|
{ 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 body | Validation or computation on access |
| Modifier | Visible to |
|---|---|
public | Everything |
private | Only this class — the default for fields |
protected | This class and classes inheriting from it |
internal | This 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.
| Keyword | Meaning |
|---|---|
abstract (class) | Cannot be instantiated; exists to be inherited |
abstract (method) | No body; every child must override it |
virtual | Has a default body; a child may override it |
override | Replaces the parent's implementation |
sealed | No 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 class | Interface | |
|---|---|---|
| Inherit how many | One | Many |
| Can hold state | Yes | Not really |
| Shares implementation | Yes | Only 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
| Message | Cause | Fix |
|---|---|---|
CS1729: 'FeeAccount' does not contain a constructor that takes 0 arguments | You wrote a constructor, so the free parameterless one disappeared | Add a parameterless constructor too, or pass the arguments |
CS0122: 'FeeAccount.PaidAmount' is inaccessible due to its protection level | Tried to set a private set property from outside | Call the method that changes it |
CS0200: Property or indexer cannot be assigned to — it is read only | Property has no setter | Set it in the constructor |
CS0534: does not implement inherited abstract member | A child class skipped an abstract method | Implement it, or make the class abstract |
CS0535: does not implement interface member | Class claims an interface it does not fulfil | Add the missing members |
System.NullReferenceException on a new object's property | The object was created but the property never assigned | Assign 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
abstractwherevirtualwas meant, or the reverse- Classes named
Manager,HelperorUtilitydoing several unrelated jobs - Depending on a concrete class where an interface would allow testing
Practice
- Write the
Studentclass with every field from the School Management System schema. - Create three students — Ravi Kumar, Priya Sharma, Arjun Reddy — using object initialisers.
- Write a
FeeAccountconstructor that rejects negative fees and an empty academic year. Prove both throw. - Add a constructor to a class that had none, then confirm
new FeeAccount()no longer compiles. - Make
PaidAmountprivate setand addRecordPayment. Try to assign the property directly. - Add validation to
RecordPaymentthat rejects a payment above the balance. Test both paths. - Replace a stored
Balancefield with a computed property, then verify it after a payment. - Add a
Salaryproperty toTeacherwith a fullsetbody that rejects negatives. - Write
Student.BuildRollNumberas a static method producingNCA-2024-0012. - Add a static
TotalStudentCountand confirm it is shared across every instance. - Build
Person→StudentandTeacher, and callGetAge()from both. - Write
abstract SchoolMemberwith an abstractGetRoleDescriptionand a virtualGetDisplayName. Override both inStudent, one inTeacher. - Put both into one
List<SchoolMember>and loop without checking types. - Add a
Staffclass and confirm the loop needs no change. - Define
IStudentRepository, implement it twice — SQL and in-memory — and swap them behindStudentService. - 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 setand a method to control how a value changes - Say what encapsulation actually protects
- Choose between inheritance, an interface, and composition
- Explain when
abstractis right and whenvirtualis - Put two different types in one list and call the same method on both
Review questions
- Why validate in the constructor rather than after creating the object?
- What does
private setonPaidAmountbuy you that a public setter does not? - When is
abstractright and when isvirtualright? - Why does
StudentServicedepend onIStudentRepositoryrather thanSqlStudentRepository?
Next: Collections and generics