Class inheritance in C#: mechanics, base calls, hiding versus overriding, protected access, the Liskov principle and common pitfalls.
Inheritance lets a derived class reuse and specialise a base class: class SavingsAccount : Account. C# allows one base class but many interfaces. The derived class inherits all members except constructors and finalisers (private ones exist but are not accessible), can call base members with base., and can override virtual/abstract members. Declaring a member with the same name without override hides it (new), and which one runs then depends on the variable's static type, a common source of bugs. Use inheritance only for a real is-a relationship where a derived object can be used anywhere the base is expected (Liskov substitution). Deep hierarchies, inheriting just to reuse code, and fragile base classes are the main problems; composition is often better.
Inheritance lets a class derive from one base class, reusing its implementation and being substitutable for it. C# supports single inheritance of classes (plus multiple interfaces). I'd cover what's inherited, base calls, overriding vs hiding, protected, and — the senior part — why deep or misused inheritance hurts and when to prefer composition.
public abstract class Notification(string recipient)
{
public string Recipient { get; } = recipient;
public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow;
public async Task SendAsync(CancellationToken ct)
{
Validate();
await DeliverAsync(ct); // varies per subclass
}
protected virtual void Validate() => ArgumentException.ThrowIfNullOrWhiteSpace(Recipient);
protected abstract Task DeliverAsync(CancellationToken ct);
}
public sealed class SmsNotification(string mobile, string text, ISmsSender sender) : Notification(mobile)
{
protected override void Validate()
{
base.Validate(); // reuse base rules
if (text.Length > 160) throw new ArgumentException("SMS too long");
}
protected override Task DeliverAsync(CancellationToken ct) => sender.SendAsync(Recipient, text, ct);
}
object); multiple class inheritance isn't allowed, avoiding the "diamond problem". Multiple interfaces are allowed.ValueType); sealed classes can't be inherited.base(...).base.Method() calls the base implementation (even if overridden). base(...) selects a base constructor. Forgetting base.Validate() in an override silently drops the base class's rules — a common fragile base class bug.
newoverride |
new (hiding) |
|
|---|---|---|
| Requires base to be | virtual/abstract/override |
Anything |
| Dispatch | Run-time type | Compile-time (declared) type |
| Called via base reference | Derived version runs | Base version runs |
Base b = new Derived();
b.Describe(); // override -> Derived.Describe; new -> Base.Describe
Hiding means the behaviour depends on the variable's declared type — almost always a bug or a sign of poor design. The compiler warns (CS0108) if you hide without new.
protected membersprotected exposes members to derived classes — including derived classes in other assemblies you don't control. Every protected member becomes part of the class's inheritance contract; changing it can break subclasses. Variants: private protected (derived types in the same assembly only) and protected internal.
ReadOnlyCollection : Collection that throws on Add; a Square : Rectangle whose setters break expectations. Callers using the base type get surprises.CustomerService : BaseService just to reuse logging/helpers — couples unrelated classes and leaks base members.PdfEmailReport, PdfSftpReport, CsvEmailReport... — two dimensions of variation modelled with inheritance instead of composition.Use inheritance for a genuine is-a relationship with substitutability and shared invariants (frameworks like ASP.NET Core's ControllerBase, BackgroundService, DelegatingHandler), keep hierarchies shallow, seal by default, and prefer composition for variation.
An importer hierarchy had BaseImporter → CsvImporter → BankCsvImporter → WestpacCsvImporter and similar branches. A change to CsvImporter.ParseLine to handle quoted fields broke two bank importers that overrode a helper it called. We replaced the hierarchy with composition: an Importer that takes an IRecordReader (CSV, fixed-width) and an IRecordMapper per bank. New banks became a mapper class plus a registration; behaviour no longer depended on override chains.
Checks mechanics plus design judgement about one of the most misused OOP features.
new hiding with static versus runtime type, protected access, LSP with an example, fragile base class, sealing by default.Each class's MethodTable contains:
is/as walk this chain or use cached checks);When a derived class overrides a method, the derived vtable's slot for that method points to the derived implementation. A virtual call loads the object's MethodTable, indexes the slot, and calls it — so Base b = new Derived(); b.M() reaches Derived.M. A new (hiding) method gets a new slot (newslot in IL), so calls through the base reference still use the base slot.
Instance fields from the base are laid out first in the object, followed by derived fields, so a reference to a derived object is also a valid base reference.
For new SmsNotification(...): derived field initialisers → base field initialisers → base constructor body → derived constructor body. Calling virtual methods from the base constructor runs derived code before the derived constructor has initialised its fields — avoid it.
Barbara Liskov's rule: objects of a subtype must be usable wherever the base type is expected without altering correctness. Concretely, a subclass must not:
NotSupportedException on Add),BCL examples of tension: Stream has CanSeek/CanWrite flags because not every stream supports everything — the contract includes the flags, which keeps it honest. ReadOnlyCollection<T> implements IList<T> and throws on Add; IList<T>.IsReadOnly exists to signal that.
public class CountingSet<T> : HashSet<T>
{
public int Added { get; private set; }
public new bool Add(T item) { Added++; return base.Add(item); }
}
Or the classic Java example: overriding both add and addAll when addAll internally calls add double-counts — depending on base implementation details you can't see. Inheritance couples you to the implementation of the base, not just its contract. Composition (wrap a set, delegate) couples only to the public interface.
If a class is meant to be inherited:
EF Core maps hierarchies with TPH (one table, discriminator column — default, fastest), TPT (table per type, joins — slower), or TPC (table per concrete type, EF Core 7+). Deep hierarchies in the domain model create complex mappings, wide sparse TPH tables or expensive TPT joins — another practical cost of inheritance.
override and with new, call both through base and derived references, and explain the output.newslot in the IL for the new version.base.Validate() and add a test that catches it.public abstract class Account(string owner)
{
public string Owner { get; } = owner;
public decimal Balance { get; protected set; }
public void Withdraw(decimal amount)
{
decimal total = amount + Fee(amount); // template step: fee varies by type
if (total > Balance) throw new InvalidOperationException("Insufficient funds");
Balance -= total;
}
protected virtual decimal Fee(decimal amount) => 2.50m;
}
public sealed class SavingsAccount(string owner) : Account(owner)
{
protected override decimal Fee(decimal amount) => 0m; // specialise
}
public sealed class BusinessAccount(string owner) : Account(owner)
{
protected override decimal Fee(decimal amount) =>
base.Fee(amount) + amount * 0.001m; // extend base behaviour
}
// Hiding: compiles with warning CS0108 unless 'new' is written, and is not polymorphic
public class Animal { public string Sound() => "..."; }
public class Dog : Animal { public new string Sound() => "Woof"; }
Animal pet = new Dog();
Console.WriteLine(pet.Sound()); // "..." (static type Animal decides)
Console.WriteLine(((Dog)pet).Sound()); // "Woof"
What happens: OrderService : BaseService to reuse logging and helper methods.
Why it is wrong: creates is-a relationships that aren't true, couples unrelated classes, and exposes base members.
Do instead: compose helpers or inject services.
new instead of overridingWhat happens: a derived class redeclares a method with new (or ignores CS0108).
Why it is wrong: behaviour depends on the declared type; calls through the base reference run the old code.
Do instead: make the base member virtual and override it, or rename the method.
base in overridesWhat happens: an override replaces base logic that should still run (validation, disposal).
Why it is wrong: silently drops invariants or resource cleanup.
Do instead: call base.Method() where the contract requires it, or use template methods so the base controls flow.
What happens: subclasses throw NotSupportedException or ignore base behaviour.
Why it is wrong: code written against the base type breaks with the subtype.
Do instead: design hierarchies so every subtype honours the base contract, or use composition/separate interfaces.
What happens: five or more levels of inheritance with overrides at each level. Why it is wrong: behaviour is hard to trace and changes ripple unpredictably. Do instead: keep hierarchies shallow (one or two levels) and compose behaviour.
What happens: classes like PdfEmailReport, CsvEmailReport, PdfSftpReport.
Why it is wrong: combinatorial explosion of classes.
Do instead: compose independent strategies (formatter + delivery channel).
What happens: fields and helpers marked protected "in case subclasses need them".
Why it is wrong: every protected member is a public contract for subclasses; changing it breaks them.
Do instead: keep state private; expose a few protected hooks deliberately.
What happens: classes are left open by default. Why it is wrong: unintended subclassing, fragile base class problems, and missed JIT devirtualisation. Do instead: seal classes unless designed for inheritance.
Seniors use inheritance deliberately and sparingly, and seal by default.
| Use inheritance when | Prefer something else when |
|---|---|
| true is-a and LSP holds | reuse is the only reason (composition) |
framework extension point (BackgroundService) |
behaviour varies at run time (strategy) |
| small closed set of variants | features combine freely (decorator) |
| template method with few hooks | many protected members are needed |
They also know the versioning angle: adding a virtual member to a public base class can break subclasses in other assemblies, so public library bases are designed for extension explicitly or sealed.
Polymorphism means one interface, many forms: code written against a base type or interface works with any implementation, and each object behaves according to its actual type. Run-time (subtype) polymorphism uses virtual/override and interfaces; the call is dispatched through the object's method table (a vtable slot for virtual methods, an interface dispatch stub for interfaces), so shape.Area() runs Circle.Area or Square.Area. Compile-time polymorphism is chosen by the compiler: method overloading, generics (and static abstract interface members for generic maths). Pattern matching on types (switch on shape) is the alternative: good for closed sets of data types and operations added often; virtual methods are better when new types are added often.
Composition means a class has other objects as parts and delegates work to them, instead of inheriting behaviour. A Car has an Engine and Wheels; an OrderProcessor has a validator, a pricer and a notifier. In the strict UML sense, composition also means ownership: the parts are created and destroyed with the whole and are not shared. Benefits: behaviour can be swapped (pass a different IPricingStrategy), combined freely (decorators wrap each other), tested in isolation, and changed without a fragile base class. The cost is a little more wiring, usually handled by constructors or dependency injection.
How to tell it (STAR)
**Situation:** At [company] a one-line change in our base report class broke the date format in three production reports. **Task:** I was asked to fix the reports and stop this kind of regression. **Action:** The base class had grown to around 40 protected members that twelve subclasses relied on in different ways, which is the fragile base class problem. I fixed the immediate bug, then replaced the hierarchy with a builder that composes a formatter, an exporter and a data source. I added snapshot tests of every report's output. **Result:** Reports now change independently; the next formatting change affected [only the intended report], and adding a new report took [hours instead of days].
Last updated 30 Oct 2026.