Quick Recap & Series Intro
This is Article 3 of 5 in our SOLID series for C# Corner. In Article 1, we covered the Single Responsibility Principle (SRP) — the idea that a class should have one reason to change. In Article 2, we covered the Open/Closed Principle (OCP) — building code that's open for extension but closed for modification.
We'll explore each remaining principle fully in its own article. Today, it's all about the "L." Let's move to the third letter: L.
If you haven't read the earlier parts yet, it's worth a quick look since these principles build on each other in practice, even though each article stands on its own:
SOLID Principles in C# Series - Part 1: Single Responsibility Principle (SRP)
SOLID Principles in C# – Part 2: The Open/Closed Principle (OCP)
Up next after this one: the Interface Segregation Principle (ISP).
What is the Liskov Substitution Principle?
In plain English: if class B is a subclass of class A, you should be able to use a B object anywhere an A object is expected, without anything breaking or behaving in a way the calling code didn't expect.
It's not just about whether the code compiles. Inheritance gives you that for free. LSP is about behavior — does the subclass honor the same promises the base class made? If a method on the base class never returns null, a subclass shouldn't suddenly start returning null. If a method never throws for valid input, an override shouldn't throw for that same input either.
When a subclass quietly changes the rules, every piece of code written against the base type becomes a potential landmine, because it was written trusting a contract that the subclass doesn't actually keep.
A General Real-World Example
Think about USB-C charging. Any phone that has a USB-C port is making a promise: plug in any standard USB-C charger, and it will charge. That's the whole point of a standard interface — you don't need to know the brand of the charger or the phone to know it'll work.
Now imagine a phone manufacturer ships a phone with a USB-C port, but it only actually charges with their own branded charger. Plug in any other standard one, and it either charges painfully slowly or not at all. The port looks like a USB-C port. It claims to be one. But it doesn't honor the contract that a USB-C port is supposed to honor.
That's exactly the kind of problem LSP is about. It's not enough for something to look like the right type from the outside — it has to behave like it too, everywhere it's used.
Why Developers Violate This Principle Without Realizing It
LSP violations almost always start with a perfectly reasonable-sounding sentence in English: "A Square is a Rectangle," or "An Intern is an Employee," or "A ReadOnlyRepository is a Repository." Grammatically, that's true. Behaviorally, it often isn't.
The trap is that inheritance gets used to model real-world "is-a" relationships instead of behavioral contracts. A developer creates a subclass, finds that one method from the base class doesn't quite make sense for this new case, and "solves" it by throwing an exception, returning a dummy value, or quietly changing what the method does. The code compiles, the tests for the new subclass pass, and everything looks fine — until some other part of the codebase calls that method through the base type and gets a surprise it was never written to handle.
C# Example 1 — Rectangle and Square
Before (Violates LSP)
public class Rectangle
{
public virtual double Width { get; set; }
public virtual double Height { get; set; }
public double Area() => Width * Height;
}
public class Square : Rectangle
{
public override double Width
{
get => base.Width;
set { base.Width = value; base.Height = value; }
}
public override double Height
{
get => base.Height;
set { base.Height = value; base.Width = value; }
}
}
This looks reasonable on its own. But consider code written against Rectangle:
void Resize(Rectangle rect)
{
rect.Width = 10;
rect.Height = 5;
Console.WriteLine(rect.Area()); // expected: 50
}
Pass in a real Rectangle, and you get 50. Pass in a Square, and setting Height silently overwrites Width too — so the result is 25, not 50.
The method never changed, but the result depends on which subclass you passed in. That's a textbook LSP violation: Square can't be substituted for Rectangle without breaking the caller's expectations.
After (Follows LSP)
public interface IShape
{
double Area();
}
public class Rectangle : IShape
{
public double Width { get; set; }
public double Height { get; set; }
public double Area() => Width * Height;
}
public class Square : IShape
{
public double Side { get; set; }
public double Area() => Side * Side;
}
What changed: instead of forcing Square to inherit Rectangle's width/height behavior it can't honestly support, both classes implement a shared IShape contract that only promises an Area(). Neither class makes promises it can't keep, so any code written against IShape behaves consistently regardless of which shape it receives.
C# Example 2 — Repository Access
Before (Violates LSP)
public interface IRepository<T>
{
T GetById(int id);
void Add(T item);
void Delete(int id);
}
public class ReadOnlyEmployeeRepository : IRepository<Employee>
{
public Employee GetById(int id)
{
/* fetch employee */
return new Employee();
}
public void Add(Employee item) =>
throw new NotSupportedException("This repository is read-only.");
public void Delete(int id) =>
throw new NotSupportedException("This repository is read-only.");
}
Anything written against IRepository reasonably assumes Add and Delete will work — that's the contract the interface advertises. Pass in a ReadOnlyEmployeeRepository, and two of the three methods blow up at runtime. The compiler is happy. The caller isn't.
After (Follows LSP)
public interface IReadableRepository<T>
{
T GetById(int id);
}
public interface IWritableRepository<T> : IReadableRepository<T>
{
void Add(T item);
void Delete(int id);
}
public class ReadOnlyEmployeeRepository : IReadableRepository<Employee>
{
public Employee GetById(int id)
{
/* fetch employee */
return new Employee();
}
}
public class EmployeeRepository : IWritableRepository<Employee>
{
public Employee GetById(int id)
{
/* fetch employee */
return new Employee();
}
public void Add(Employee item)
{
/* insert employee */
}
public void Delete(int id)
{
/* delete employee */
}
}
What changed: ReadOnlyEmployeeRepository only implements the interface it can fully honor. Nothing that depends on IReadableRepository will ever be surprised by a missing write capability, because that capability was never promised in the first place.
Notice this is also a preview of next article's topic — splitting a bloated interface into smaller, honest ones is the Interface Segregation Principle at work.
Applying This in Our HRMS Project
Let's bring this into HRMS. Imagine an Employee base class with a CalculateBonus() method, and an Intern subclass:
public class Employee
{
public virtual decimal CalculateBonus(decimal baseSalary) =>
baseSalary * 0.10m;
}
public class Intern : Employee
{
public override decimal CalculateBonus(decimal baseSalary) =>
throw new InvalidOperationException("Interns are not eligible for bonus.");
}
This looks harmless until PayrollService loops over a list of Employee objects to process year-end bonuses:
foreach (var employee in employees)
totalPayout += employee.CalculateBonus(employee.BaseSalary);
The moment that list contains an Intern, the entire payroll run crashes — for a method that every other Employee in the list handles fine. The bug isn't in the loop; it's in the inheritance decision made earlier.
A cleaner approach separates bonus eligibility from the base type entirely:
public interface IBonusEligible
{
decimal CalculateBonus(decimal baseSalary);
}
public class PermanentEmployee : Employee, IBonusEligible
{
public decimal CalculateBonus(decimal baseSalary) =>
baseSalary * 0.10m;
}
public class Intern : Employee
{
// No CalculateBonus method — interns simply don't implement IBonusEligible.
}
Now PayrollService only calls CalculateBonus on employees that actually implement IBonusEligible, using a simple type check or pattern match. No Employee subtype is forced to support a behavior it can't honestly provide, and nothing crashes because a contract was never broken in the first place.
Common Mistakes / Pitfalls When Applying This Principle
The most common pitfall is treating English "is-a" phrasing as proof that inheritance is the right tool — a Square is grammatically a Rectangle, an Intern is grammatically an Employee, but neither necessarily behaves like one in every situation the base type promises.
Another frequent mistake is overriding a method to throw NotImplementedException or NotSupportedException just to satisfy the compiler when a subclass doesn't truly fit; if a class can't deliver on the full contract, it shouldn't implement that contract at all.
Developers also sometimes strengthen preconditions in an override (suddenly rejecting input the base class accepted) or weaken postconditions (suddenly allowing a null return where the base class guaranteed a value) — both quietly break code written against the base type.
Finally, a subtle one: relying on if (employee is Intern) checks scattered through the codebase is often a sign that the inheritance hierarchy itself needs rethinking, rather than patching around it.
Quick Checklist
Can every subclass be used wherever the base type or interface is expected, with no special-case handling by the caller?
Does any override throw an exception, return null, or behave unexpectedly compared to what the base type promises?
Are you strengthening preconditions or weakening postconditions in any overridden method?
Do you see
isorastype checks scattered around code that's supposed to work generically against a base type?If you described this relationship in plain English ("X is a Y"), does X actually behave like a Y in every situation Y is used?
Conclusion
The Liskov Substitution Principle is really about trust — code written against a base type or interface should be able to trust every subclass to honor that same contract, with no exceptions and no surprises.
Get this right, and polymorphism works the way it's supposed to: predictably.
Next up in this series: Article 4 — The Interface Segregation Principle (ISP), where we'll look at why forcing classes to implement methods they don't need causes more harm than it seems to.

Join the conversation! Your thoughts help the community grow.