Inheritance is the feature most object-oriented programmers learn first. It’s also one of the most misused. Used well, it gives you a single abstraction that many types can stand behind, and calling code that never needs to know which one it has. Used badly, it gives you hierarchies five levels deep, base classes nobody dares to change, and subclasses that throw NotSupportedException from half the methods they inherited. The difference usually comes down to one question: can the subclass really be used anywhere its base class is expected, and was the base class designed to be extended? If both answers are yes, inheritance is a good tool. If either is no, composition is almost always the better choice.
I’ve written before about .NET exception best practices and the programming principles behind “favor composition over inheritance”. This post is about inheritance itself: what goes wrong, where it’s the right choice, and a few questions to ask before you type : BaseClass. The examples are in C#, but most of the ideas apply to any object-oriented language.
What goes wrong with inheritance
Tight coupling and the fragile base class
A subclass depends on more than its base class’s public API. It also depends on implementation details, such as which methods call which, in what order, and whether a method is virtual. That makes the base class fragile, because a change that looks harmless can break subclasses in ways neither author expected. Joshua Bloch’s Effective Java has a well-known example of this, and here it is adapted to C#:
public class Inventory
{
protected readonly List<Item> Items = new();
public virtual void Add(Item item) => Items.Add(item);
public virtual void AddRange(IEnumerable<Item> items)
{
foreach (Item item in items)
{
Add(item);
}
}
}
public class CountingInventory : Inventory
{
public int AddedCount { get; private set; }
public override void Add(Item item)
{
AddedCount++;
base.Add(item);
}
public override void AddRange(IEnumerable<Item> items)
{
AddedCount += items.Count();
base.AddRange(items); // the base calls Add for each item, so every item is counted twice
}
}
Removing the subclass’s AddRange override fixes the bug, but only until someone optimizes Inventory.AddRange to call Items.AddRange directly. Then the subclass silently stops counting bulk additions. The subclass is correct only as long as the base class keeps an internal detail that nobody wrote down. A wrapper that holds an Inventory and counts calls on its way through doesn’t have this problem, because it only depends on the public API.
Inheriting things you don’t need
When a class inherits from a base class just to reuse some of its code, it gets every public member of that base class whether it wants them or not. The usual result is overrides that throw:
public class ArchivedOrderRepository : OrderRepository
{
public override Task SaveAsync(Order order) =>
throw new NotSupportedException("Archived orders are read-only.");
}
Any code that takes an OrderRepository and calls SaveAsync compiles fine and fails at runtime. This breaks the Liskov Substitution Principle, which says a subclass has to work anywhere its base type is expected. The fix is to split the abstraction, for example into an IOrderReader and an IOrderWriter, and have the archive implement only the reader.
Hierarchies built on categories that change
Inheritance fixes an object’s type when the object is created, and the object keeps that type for its whole life. That’s a problem when the category you’re modeling can change. Employees are the classic example:
public class Employee
{
public required string Name { get; init; }
}
public class FullTimeEmployee : Employee
{
public decimal AnnualSalary { get; init; }
}
public class Contractor : Employee
{
public decimal HourlyRate { get; init; }
}
This looks tidy until a contractor is hired full-time, or an intern becomes a part-time employee. The object can’t change its type, so you have to create a new object, copy the data across, and hope nothing still holds a reference to the old one. Employment type is a fact about a person that can change over time, so model it as data:
public class Employee
{
public required string Name { get; init; }
public required Compensation Compensation { get; set; }
}
public abstract record Compensation;
public sealed record Salary(decimal Annual) : Compensation;
public sealed record Hourly(decimal PerHour) : Compensation;
Promoting a contractor now means changing one property. The small record hierarchy is itself a reasonable use of inheritance. It’s a closed set of immutable values, the subtypes are sealed, and there’s no behavior to override. In effect, it stands in for the discriminated unions C# doesn’t have yet.
Deep hierarchies make this problem worse. Each level adds another set of assumptions that the levels below depend on, and to understand what a class at level five does, you have to read all five. If your hierarchy is more than two or three levels deep, consider flattening it.
Virtual calls from constructors
A C# base class constructor runs before the derived class’s constructor body. If the base constructor calls a virtual method, it runs the derived class’s override before the derived class has finished setting up its fields:
public class ReportBase
{
protected ReportBase() => Title = BuildTitle();
public string Title { get; }
protected virtual string BuildTitle() => "Report";
}
public class SalesReport : ReportBase
{
private readonly string _region;
public SalesReport(string region) => _region = region;
// Runs from the base constructor, before _region is assigned
protected override string BuildTitle() => $"Sales: {_region.ToUpper()}";
}
new SalesReport("EMEA") throws a NullReferenceException. The analyzer rule CA2214 warns about this, so make it a build error.
Multiple inheritance and the diamond problem
C# and Java don’t allow a class to inherit from more than one base class, and that rules out most of the problems people associate with multiple inheritance. The diamond problem is the best known. Class D inherits from B and C, which both inherit from A. If B and C both override a method from A, which version does D get? C++ answers this with virtual inheritance, and Python with its method resolution order. Both work, and both make the code harder to reason about.
C# still has a small version of the problem. Since C# 8, interfaces can provide default implementations of their methods. If a class implements two interfaces that each provide a default implementation of the same inherited member, the compiler reports an error until the class provides its own implementation. That’s the right outcome: you resolve the ambiguity explicitly instead of the language choosing for you. Circular inheritance, where A inherits from B and B inherits from A, isn’t possible in C# at all. The compiler rejects it with error CS0146.
Where inheritance is the right choice
.NET itself uses inheritance well in a handful of patterns. They’re good models for your own code.
One stable abstraction with many implementations
Stream is an abstract class with dozens of implementations, including FileStream, MemoryStream, NetworkStream, GZipStream and CryptoStream. Code written against Stream works with all of them, including ones that didn’t exist when the code was written:
public static async Task<string> ComputeHashAsync(Stream input, CancellationToken ct)
{
byte[] hash = await SHA256.HashDataAsync(input, ct);
return Convert.ToHexString(hash);
}
That’s polymorphism. Calling code works with any subtype without type checks or switch statements. Stream works well as a base class because it was designed for extension. Its contract is documented in detail, and capabilities that only some streams have are exposed through properties like CanSeek and CanWrite, instead of being something callers find out about from a thrown exception.
Framework hooks: the template method pattern
A base class can run a fixed sequence of steps and let subclasses fill in one of them. BackgroundService handles starting, stopping and integration with the host, and you only override ExecuteAsync:
public sealed class OutboxPublisher(IOutbox outbox) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await outbox.PublishPendingAsync(stoppingToken);
}
}
}
ControllerBase, DbContext, Blazor’s ComponentBase and JsonConverter<T> work the same way. Each one is a base class that a framework designed specifically for you to extend, with a small set of documented extension points.
Exception hierarchies
Exceptions are one of the clearest real is-a relationships. A ProductNotFoundException is a kind of NotFoundException, which is a kind of Exception. Callers can catch at whatever level of detail they need, and a new, more specific exception can be added without breaking catch blocks that already exist. I cover this in the exception best practices post.
Genuine specialization
Sometimes a subtype really is a more specific version of its base type, and always will be. It adds to what the base does without changing what callers can rely on. A CachedProductCatalog that adds caching on top of a ProductCatalog designed for extension is one example. Even then, check whether a decorator would do the same job. A decorator implements the same interface and wraps the original object, so it adds the behavior without depending on the base class’s internals.
Questions to ask before you inherit
- Can the subclass be used anywhere the base class is used, without any caller noticing? If you’d have to override a method just to throw, the answer is no.
- Will this “is a” relationship hold for the object’s whole life? If the category can change, model it as data or as a composed object.
- Was the base class designed for extension, with documented virtual members and a clear contract? If it wasn’t, wrap it instead of inheriting from it.
- Do you want the base class’s behavior, or only its code? If you only want to reuse code, extract it into a helper class, an extension method or a composed service.
- Will the hierarchy stay shallow? Two levels is comfortable. More than three usually means the design is trying to model something that isn’t a tree.
There’s also a default worth changing. C# classes can be inherited from unless you say otherwise, but most classes were never designed for it. Bloch’s advice applies here: design and document a class for inheritance, or prohibit it. Mark classes sealed unless you intend them to be extended. Since .NET 7, analyzer rule CA1852 flags internal classes that could be sealed, and sealing also lets the JIT optimize some calls.
The alternatives
When inheritance doesn’t fit, C# has several other ways to share behavior:
- Composition: hold a reference to another object and delegate to it, injected through the constructor. You can swap or decorate the behavior without touching a base class.
- Interfaces: declare what an object can do without saying how it does it, and implement as many interfaces as you need.
- Default interface methods: provide shared behavior on an interface for the cases that need it, without forcing a common base class.
- Extension methods: add helpers to types you don’t own, or to an interface, without inheriting from anything.
- Generics: reuse an algorithm across types with constraints such as
where T : IComparable<T>, instead of requiring a base class.
Further reading
Inheritance in C# | Microsoft Learn
Designing for extensibility | .NET Framework Design Guidelines
CA2214: Do not call overridable methods in constructors
