Skip to main content

Polymorphism: virtual, override and Fee Notices – C# | Lesson 40

Published / updated

Introduction​

At the end of Lesson 39, a switch checked each person's type to describe them. Add a new kind of person, such as a librarian, and you must find and update every such switch in the program.

Polymorphism ("many forms") lets each class answer for itself. You call the same method on any Person, and each object runs its own version. This is the third OOP pillar, and the one that makes large programs easy to extend.

By the end of this lesson, you will be able to:

  • Mark methods and properties virtual, and replace them with override.
  • Reuse the base version with base.Method().
  • Override ToString() so objects print sensibly.
  • Use one variable type for several behaviours, such as email and phone notices.
  • Explain overriding versus overloading, and avoid the new keyword trap.

Why this matters​

In Lesson 35, guardians without an email had to be contacted by phone. With polymorphism, the program chooses the channel once. Everything after that, composing and sending the notice, is the same call for every family.

Polymorphism is what makes the Web API extensible. In Module 12, a new kind of report, payment method, or notification is a new class, not an edit to twenty existing switch statements. Interviewers ask about it in almost every C# interview.

The concept​

Example: one call, many behaviours​

Think of the school bell. It rings once, the same signal for everyone. The Class 1 students line up for assembly, the sports team heads to the field, and the library volunteers open the doors.

The bell does not know who is listening or what they will do. In code, the bell is the method call, and each class decides its own reaction.

Example: virtual and override​

KeywordWhereMeaning
virtualBase class"Derived classes may replace this"
overrideDerived class"Here is my replacement"
base.Describe()Inside an override"Run the base version too"
Example
// In Person
public virtual string Describe() => FullName;

// In Student
public override string Describe() => $"{base.Describe()} (student {AdmissionNumber})";

When code calls person.Describe(), .NET looks at the real object at run time. A Student runs the student version, even through a Person variable. This is called run-time polymorphism, or dynamic dispatch.

Example: overriding versus overloading​

The two words sound alike and are asked about together in interviews:

Overloading (Lesson 34)Overriding (this lesson)
WhatSame name, different parametersSame name, same parameters, new body
WhereUsually in one classBase class and derived class
ChosenAt compile time, from the argument typesAt run time, from the object's real type
KeywordsNonevirtual and override

Apply it to the SMS​

Step 1: people that describe themselves​

Person gets a virtual Describe() and overrides ToString(), and each derived class overrides Describe():

src/SchoolApp.Core/Entities/Person.cs
namespace SchoolApp.Core.Entities;

// Lesson 39: what students, teachers, and guardians have in common.
// Each still has its own table in SchoolDb; inheritance shares C# code, not tables.
public class Person
{
private string _firstName = "";
private string _lastName = "";

public int Id { get; set; }

public string FirstName
{
get => _firstName;
set => _firstName = RequireText(value, nameof(FirstName));
}

public string LastName
{
get => _lastName;
set => _lastName = RequireText(value, nameof(LastName));
}

public Person(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
}

public string FullName => $"{FirstName} {LastName}";

// Lesson 40: virtual lets each derived class describe itself in its own way.
public virtual string Describe() => FullName;

// object.ToString() is virtual too; overriding it changes what printing an object shows.
public override string ToString() => Describe();

// protected: usable here and in every class that inherits from Person.
protected static string RequireText(string value, string propertyName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"{propertyName} is required.", propertyName);
}
return value.Trim();
}
}
Show the overrides in Student, Teacher, and Guardian
src/SchoolApp.Core/Entities/Student.cs
namespace SchoolApp.Core.Entities;

// Lesson 37: a class describes what every student has (properties) and can do (methods).
// Lesson 38: names are checked and trimmed; the admission number can never change.
// Lesson 39: names, Id, and FullName now come from the Person base class.
public class Student : Person
{
public string AdmissionNumber { get; } // set once, in the constructor
public DateOnly DateOfBirth { get; set; }

// base(...) runs Person's constructor first, which checks the names.
public Student(string admissionNumber, string firstName, string lastName, DateOnly dateOfBirth)
: base(firstName, lastName)
{
AdmissionNumber = RequireText(admissionNumber, nameof(AdmissionNumber));
DateOfBirth = dateOfBirth;
}

// Lesson 40: override replaces Person's Describe; base.Describe() reuses it.
public override string Describe() => $"{base.Describe()} (student {AdmissionNumber})";

// A method: the age on a given date, the same rule as Lesson 29's SQL task.
public int GetAgeOn(DateOnly date)
{
int age = date.Year - DateOfBirth.Year;
if (DateOfBirth > date.AddYears(-age))
{
age--; // the birthday has not happened yet in that year
}
return age;
}
}
src/SchoolApp.Core/Entities/Teacher.cs
namespace SchoolApp.Core.Entities;

// Lesson 39: a teacher is a Person with an employee code and contact details.
public class Teacher : Person
{
public string EmployeeCode { get; }
public string Email { get; set; }
public string Phone { get; set; }

public Teacher(string employeeCode, string firstName, string lastName, string email, string phone)
: base(firstName, lastName)
{
EmployeeCode = RequireText(employeeCode, nameof(EmployeeCode));
Email = RequireText(email, nameof(Email));
Phone = RequireText(phone, nameof(Phone));
}

public override string Describe() => $"{base.Describe()} (teacher {EmployeeCode})";
}
src/SchoolApp.Core/Entities/Guardian.cs
namespace SchoolApp.Core.Entities;

// Lesson 39: a guardian is a Person with a phone number; email and occupation are optional.
public class Guardian : Person
{
public string Phone { get; set; }
public string? Email { get; set; }
public string? Occupation { get; set; }

public Guardian(string firstName, string lastName, string phone)
: base(firstName, lastName)
{
Phone = RequireText(phone, nameof(Phone));
}

public override string Describe() => $"{base.Describe()} (guardian, phone {Phone})";
}

Step 2: fee notices​

A new folder, Notifications, holds a base class and two channels. The notice is only composed as text here. Lesson 94 sends notices from a background worker in the Web API; connecting a real email or text-message provider is then one more class behind the same interface.

src/SchoolApp.Core/Notifications/FeeNotice.cs
using SchoolApp.Core.Entities;

namespace SchoolApp.Core.Notifications;

// Lesson 40: one base class, different behaviour per channel (polymorphism).
// The notice is only composed here; really sending it comes in the Web API modules.
public class FeeNotice
{
public virtual string Channel => "Letter";

// The message text. Derived classes can change how it is written.
public virtual string Compose(Guardian guardian, string studentName, decimal balance)
{
return balance == 0
? $"Dear {guardian.FullName}, thank you: the fees for {studentName} are paid in full."
: $"Dear {guardian.FullName}, the fee balance for {studentName} is {balance}. Please pay by the due date.";
}

// Not virtual: every notice is sent the same way; only Channel and Compose differ.
public string Send(Guardian guardian, string studentName, decimal balance)
{
return $"[{Channel}] {Compose(guardian, studentName, balance)}";
}
}

public class EmailFeeNotice : FeeNotice
{
public override string Channel => "Email";

// An email gets a subject line in front of the normal text.
public override string Compose(Guardian guardian, string studentName, decimal balance)
{
return $"Subject: Fees for {studentName} | {base.Compose(guardian, studentName, balance)}";
}
}

public class PhoneFeeNotice : FeeNotice
{
public override string Channel => "Phone";

// A text message must be short.
public override string Compose(Guardian guardian, string studentName, decimal balance)
{
return balance == 0
? $"{studentName}: fees paid in full. Thank you. NexCoding Public School"
: $"{studentName}: fee balance {balance}. NexCoding Public School";
}
}

Step 3: the program​

Show src/SchoolApp.Console/Program.cs
src/SchoolApp.Console/Program.cs
// Lesson 40: polymorphism. One call, different behaviour for different types.
using SchoolApp.Core.Entities;
using SchoolApp.Core.Notifications;

Student aarav = new Student("ADM-2017-0001", "Aarav", "Sharma", new DateOnly(2011, 4, 12));
Teacher anitha = new Teacher("TCH-001", "Anitha", "Rao", "anitha.rao@nexcodingschool.example", "9876540001");
Guardian rajesh = new Guardian("Rajesh", "Sharma", "9876540101") { Email = "rajesh.sharma@nexcodingschool.example" };

// 1. Each class describes itself. No switch on the type is needed any more.
Person[] people = { aarav, anitha, rajesh };
foreach (Person person in people)
{
Console.WriteLine(person.Describe());
}
Console.WriteLine($"Printing the object now calls the override: {aarav}");

// 2. Fee notices for three real families. The channel is chosen once; Send does the rest.
Guardian suresh = new Guardian("Suresh", "Patel", "9876540103");
Guardian harpreet = new Guardian("Harpreet", "Singh", "9876540105");

SendNotice(rajesh, "Aarav", 0m);
SendNotice(suresh, "Diya", 25500m);
SendNotice(harpreet, "Kabir", 45500m);

// 3. Hiding with new is not overriding: a call through the base type ignores it.
FeeNotice shortNotice = new ShortPhoneNotice();
ShortPhoneNotice sameNotice = (ShortPhoneNotice)shortNotice;
Console.WriteLine($"Through FeeNotice: {shortNotice.Compose(suresh, "Diya", 25500m)}");
Console.WriteLine($"Through ShortPhoneNotice: {sameNotice.Compose(suresh, "Diya", 25500m)}");

static void SendNotice(Guardian guardian, string studentName, decimal balance)
{
FeeNotice notice = guardian.Email is null ? new PhoneFeeNotice() : new EmailFeeNotice();
Console.WriteLine(notice.Send(guardian, studentName, balance));
}

// new hides the base method instead of overriding it. Usually a mistake.
class ShortPhoneNotice : PhoneFeeNotice
{
public new string Compose(Guardian guardian, string studentName, decimal balance)
{
return $"{studentName}: {balance} due";
}
}
Output
Aarav Sharma (student ADM-2017-0001)
Anitha Rao (teacher TCH-001)
Rajesh Sharma (guardian, phone 9876540101)
Printing the object now calls the override: Aarav Sharma (student ADM-2017-0001)
[Email] Subject: Fees for Aarav | Dear Rajesh Sharma, thank you: the fees for Aarav are paid in full.
[Phone] Diya: fee balance 25500. NexCoding Public School
[Phone] Kabir: fee balance 45500. NexCoding Public School
Through FeeNotice: Diya: fee balance 25500. NexCoding Public School
Through ShortPhoneNotice: Diya: 25500 due

Example: no more type switch​

The loop calls person.Describe() on a Person variable, and each object answers in its own way. Compare Lesson 39's switch with one arm per type.

To add a Librarian now, you write one new class with its own Describe(). No existing code changes. That is the practical value of polymorphism: new behaviour by adding code, not by editing working code.

Example: overriding ToString​

ToString() is a virtual method of object. Lesson 37 printed SchoolApp.Core.Entities.Student; now {aarav} in an interpolated string prints the student's description, because interpolation calls ToString().

The debugger also shows ToString() in its windows, so a good override makes debugging easier too. Keep it short and safe: never put passwords or other private data in ToString(), because it ends up in logs.

Example: real families, real channels​

The three notices use real SchoolDb data. Rajesh Sharma is the only guardian with an email, and his son Aarav's invoice is paid, so he gets a thank-you email. Diya's and Kabir's fathers have no email on record, so they get phone messages with the real balances.

SendNotice chooses the class once: PhoneFeeNotice or EmailFeeNotice, stored in a FeeNotice variable. After that line, the code does not care which one it has.

Example: a fixed process with changing steps​

Send is not virtual: every notice is sent the same way, with the channel in brackets and then the text. Inside, it calls the virtual Channel and Compose, which each class fills in differently.

This shape, a fixed method calling replaceable steps, is called the Template Method pattern. The base class controls the process; derived classes control the details.

Example: properties can be virtual too​

Channel is a virtual property, not a method: public virtual string Channel => "Letter";. EmailFeeNotice overrides it with "Email".

Anything that has a body, such as methods, properties, and indexers, can be virtual. Fields cannot.

Example: stopping further overrides​

A derived class can write public sealed override string Describe(). Its own override still works, but classes that inherit from it can no longer replace it.

Use it when a later override would break a rule the class relies on. Lesson 42 shows sealed on whole classes.

Example: when a subclass is the wrong tool​

Not every difference needs a new class. If two kinds of notice differed only in a number, such as a maximum length, a constructor parameter or a property value would be enough.

DifferenceBetter tool
Only a value differs, such as a rate or a limitA property set in the constructor
The behaviour differs, such as how the text is writtenA subclass with an override
Behaviour must be swapped at run time, or mixed and matchedAn interface (Lesson 41)

The homework's magazine fine of 1 could also be a constructor value. Overriding is shown there to practise the syntax; in a real codebase, choose the simplest tool that fits.

Example: the new keyword trap​

ShortPhoneNotice declares Compose with new instead of override. That hides the method instead of replacing it. Which version runs now depends on the variable's type:

Called throughRuns
A FeeNotice variablePhoneFeeNotice.Compose: the long text
A ShortPhoneNotice variableThe hiding version: Diya: 25500 due

Same object, two answers. Code that receives it as a FeeNotice, such as SendNotice, silently ignores the new method. Without new or override, the compiler warns: warning CS0114: ... hides inherited member ... To make the current member override that implementation, add the override keyword. Almost always, override is what you meant.

Example: where you will meet polymorphism next​

ModulePolymorphism in action
8List<T>, arrays, and query results all work with the same LINQ methods
9One repository interface, with an ADO.NET, a Dapper, and an EF Core version
11Ok(...), NotFound(), and BadRequest(...) are different result objects behind one return type
12Logging writes to the console, a file, or a cloud service through the same calls

In each case, the calling code stays the same while the object behind it changes. Once you see that pattern, large frameworks become much easier to read.

Common mistakes​

  1. Forgetting virtual in the base class. override then fails with error CS0506: cannot override inherited member ... because it is not marked virtual, abstract, or override. Fix: mark the base member virtual, if replacing it makes sense.
  2. Hiding instead of overriding. Missing override gives warning CS0114, and the base version runs through base-type variables. Fix: write override.
  3. Forgetting base.Method() when you want to extend. You lose the base behaviour. Fix: call base.Describe() and add to it.
  4. Making everything virtual. Every virtual member is a promise that derived classes may change it. Fix: keep processes like Send non-virtual, and open only the steps.
  5. Type switches that grow with every new class. Fix: move the behaviour into the classes as an override.

In real projects​

From 16 years of .NET projects

One school system had a 300-line method with a switch on the payment mode: Cash, Card, Online, Cheque. Adding bank transfers meant editing it in eleven places, and two were missed.

We replaced it with a base class and one small class per payment mode, each with its own validation and receipt text. Adding the next mode took one new file and one line where the class is chosen.

That line, choosing the class once, is the only switch left. Everything after it is polymorphism.

Best practices​

  • Replace type switches with virtual methods on the classes themselves.
  • Choose the concrete class in one place, then work through the base type.
  • Override ToString() for classes you print or inspect in the debugger.
  • Keep the overall process non-virtual, and make only the varying steps virtual.
  • Never use new to hide a method unless you have a very specific reason.

Recap​

  • Polymorphism: one call, and each object runs its own version.
  • virtual allows replacement; override replaces; base.Method() reuses the base version.
  • .NET picks the override from the object's real type at run time.
  • Overloading differs in parameters and is chosen at compile time; overriding is chosen at run time.
  • ToString() is virtual on object; overriding it changes how objects print.
  • new hides a method instead of overriding it, so base-type callers never see it.

Homework​

Library module task: Write a LibraryItem class with a virtual FinePerDay of 2 and a virtual CanBeIssued of true, plus a non-virtual CalculateFine(daysLate).

Derive Magazine, which overrides the fine to 1 per day, and ReferenceBook, which overrides CanBeIssued to false. Loop over one of each and show the fine for 5 days late, or "reference only".

A correct result looks like:

Output
The Jungle Book fine for 5 days late: 10
Young Scientist fine for 5 days late: 5
School Atlas reference only, cannot be issued
Show the solution
library-assignment/040-library-polymorphism.cs
// Library homework, Lesson 40: each kind of item answers for itself (polymorphism).
// Run with: dotnet run library-assignment/040-library-polymorphism.cs
LibraryItem[] items =
{
new LibraryItem("The Jungle Book"),
new Magazine("Young Scientist"),
new ReferenceBook("School Atlas"),
};

foreach (LibraryItem item in items)
{
string loan = item.CanBeIssued ? $"fine for 5 days late: {item.CalculateFine(5)}" : "reference only, cannot be issued";
Console.WriteLine($"{item.Title,-16} {loan}");
}

class LibraryItem
{
public string Title { get; }

public LibraryItem(string title)
{
Title = title;
}

public virtual decimal FinePerDay => 2m;
public virtual bool CanBeIssued => true;

// Not virtual: the calculation is the same; only the rate differs.
public decimal CalculateFine(int daysLate) => daysLate > 0 ? daysLate * FinePerDay : 0m;
}

class Magazine : LibraryItem
{
public Magazine(string title) : base(title) { }

public override decimal FinePerDay => 1m;
}

class ReferenceBook : LibraryItem
{
public ReferenceBook(string title) : base(title) { }

public override bool CanBeIssued => false;
}

CalculateFine is written once and stays non-virtual. It uses the virtual FinePerDay, so each kind of item gets the right fine without its own copy of the calculation.

Quiz​

  1. Which keyword in the base class allows a derived class to replace a method?

    1. override
    2. virtual
    3. new
    4. base
    Show answer

    Answer: B. virtual The base marks it virtual; the derived class uses override.

  2. Person p = aarav; p.Describe(); where aarav is a Student that overrides Describe. Which version runs?

    1. Person's
    2. Student's
    3. Both
    4. Neither
    Show answer

    Answer: B. Student's The real object's type decides at run time.

  3. What is the difference between overloading and overriding?

    1. They are the same
    2. Overloading changes parameters; overriding replaces a virtual method's body
    3. Overriding changes parameters; overloading replaces a body
    4. Overloading needs virtual
    Show answer

    Answer: B. Overloading changes parameters; overriding replaces a virtual method's body Overloads are chosen at compile time; overrides at run time.

  4. Why does printing aarav now show the student's description?

    1. Console.WriteLine reads properties
    2. Person overrides ToString
    3. Students are strings
    4. Interpolation calls Describe
    Show answer

    Answer: B. Person overrides ToString Interpolation calls ToString, which Person now overrides.

  5. A method is declared with new instead of override. What happens when it is called through a base-type variable?

    1. The new method runs
    2. The base version runs
    3. An exception
    4. It does not compile
    Show answer

    Answer: B. The base version runs new hides the method only for variables of the derived type.

Interview questions​

Questions are grouped by experience level. Click a question to see a model answer. All questions: OOP interview questions.

Fresher (0-2 yrs)​

F What is polymorphism? concept

One call behaving differently depending on the object's real type. Calling Describe() on a Person variable runs the student, teacher, or guardian version, depending on which object it holds.

See the example · Permalink

F What do virtual and override do? concept

virtual in the base class allows derived classes to replace a method or property. override in the derived class provides the replacement. base.Method() inside an override runs the base version too.

See the example · Permalink

F Why would you override ToString? concept

By default it returns the class name, such as SchoolApp.Core.Entities.Student. Overriding it gives a readable description, which interpolation, logging, and the debugger then use. Never put private data such as passwords in it.

See the example · Permalink

Mid (3-5 yrs)​

M What is the difference between method overloading and method overriding? concept

Overloading: same name, different parameters, usually in one class, chosen at compile time. Overriding: same signature, new body in a derived class, needs virtual and override, chosen at run time from the object's real type.

See the example · Permalink

M What is the difference between override and new on a method? concept

override replaces the base method for every caller. new hides it: callers using a base-type variable still get the base version, and only derived-type variables see the new one. Hiding is almost always a mistake; the compiler warns with CS0114 when neither keyword is used.

See the example · Permalink

M What is the Template Method pattern? concept

A non-virtual method defines a fixed process and calls virtual (or abstract) steps that derived classes fill in. FeeNotice.Send always formats the same way, while Channel and Compose differ per channel.

See the example · Permalink

Senior (6-9 yrs)​

S A 300-line switch on payment mode must change for every new mode. How would you refactor it? scenario

Create a base class or interface for payment modes with the varying behavior as virtual or abstract members, and one class per mode. Choose the class in one place, then call it through the base type. A new mode becomes one new class, with no edits to existing logic.

How to answer: Describe the steps (extract behavior, one class per case, single creation point), the benefit (open for extension, closed for modification), and safety (tests per mode before removing the switch).

See the example · Permalink

S When is creating a subclass the wrong way to handle a difference? design

When only a value differs, such as a rate or a limit: use a property set in the constructor instead. Use subclasses for different behavior, and interfaces when behavior must be swapped or combined at run time.

How to answer: Show judgment with examples, and mention the cost of unnecessary classes: more code, deeper hierarchies, and harder changes.

See the example · Permalink

Architect / Lead (10+ yrs)​

A How does polymorphism help a large system grow without breaking existing features? design

New behavior is added as new classes behind an existing base type or interface, so tested code is not edited. Only the place that chooses the implementation changes. This keeps changes small, reviewable, and low-risk, and lets teams work on different implementations independently.

How to answer: Connect it to the Open/Closed Principle, plug-in architectures, and dependency injection in ASP.NET Core, with an SMS example such as notification channels.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-40-polymorphism (code repository coming soon)
  • Files: src/SchoolApp.Core/Entities/Person.cs, src/SchoolApp.Core/Notifications/FeeNotice.cs, src/SchoolApp.Console/Program.cs, library-assignment/040-library-polymorphism.cs
  • Video: coming soon

What's next​

Should anyone be able to create a plain Person, or a plain FeeNotice? Next: abstract classes, which can only be inherited from, and interfaces, which describe what a class can do without any code. Lesson 41: Abstract classes and interfaces

Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.