Understanding Inheritance in C#
Myth: Inheritance is always the best way to reuse code in object-oriented programming. Reality: Inheritance creates tight coupling between base and derived classes, and it's often the wrong choice when composition would work better.
Inheritance works best when you have a true is-a relationship and need polymorphism. A Dog is an Animal. A Manager is an Employee. These relationships justify inheritance because derived classes can substitute for base classes anywhere in your code. When you need shared behavior without that substitutability, composition usually wins.
You'll learn how to create proper inheritance hierarchies, use virtual and override correctly, call base class methods, and recognize when inheritance isn't the right tool for the job.
Creating a Base Class and Derived Class
Inheritance in C# uses a colon syntax to indicate that one class derives from another. The derived class automatically gets all public and protected members from the base class. You then add specialized behavior or override base functionality to customize how the derived class works.
Start with a simple example showing how to create a base Employee class and derive specific employee types from it. Each derived class inherits common employee functionality while adding its own specific behavior.
namespace CompanyHR;
public class Employee
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public decimal BaseSalary { get; set; }
public Employee(string firstName, string lastName, decimal baseSalary)
{
FirstName = firstName;
LastName = lastName;
BaseSalary = baseSalary;
}
public string GetFullName()
{
return $"{FirstName} {LastName}";
}
public virtual decimal CalculateBonus()
{
// Default bonus is 5% of base salary
return BaseSalary * 0.05m;
}
public virtual void DisplayInfo()
{
Console.WriteLine($"Employee: {GetFullName()}");
Console.WriteLine($"Salary: ${BaseSalary:N2}");
Console.WriteLine($"Bonus: ${CalculateBonus():N2}");
}
}
The base Employee class defines common properties and behaviors all employees share. Notice the virtual keyword on CalculateBonus and DisplayInfo—this makes these methods overrideable in derived classes. Without virtual, derived classes can't change this behavior polymorphically.
namespace CompanyHR;
public class Manager : Employee
{
public int TeamSize { get; set; }
public decimal PerformanceRating { get; set; }
public Manager(string firstName, string lastName, decimal baseSalary,
int teamSize, decimal performanceRating)
: base(firstName, lastName, baseSalary)
{
TeamSize = teamSize;
PerformanceRating = performanceRating;
}
public override decimal CalculateBonus()
{
// Managers get 10% base bonus plus team size multiplier
decimal baseBonus = BaseSalary * 0.10m;
decimal teamBonus = TeamSize * 500m;
decimal perfBonus = baseBonus * PerformanceRating;
return baseBonus + teamBonus + perfBonus;
}
public override void DisplayInfo()
{
base.DisplayInfo(); // Call base implementation first
Console.WriteLine($"Team Size: {TeamSize}");
Console.WriteLine($"Performance: {PerformanceRating:F2}");
}
}
Manager inherits from Employee using : Employee syntax. The constructor chains to the base constructor using : base() to initialize inherited properties. The overridden methods use the override keyword to replace base behavior. In DisplayInfo, calling base.DisplayInfo() lets you extend rather than completely replace base functionality.
Leveraging Polymorphism
The real power of inheritance is polymorphism—treating derived objects through base class references. This lets you write code that works with the base class but automatically uses derived class behavior at runtime.
var employees = new List<Employee>
{
new Employee("John", "Smith", 50000),
new Manager("Sarah", "Johnson", 80000, 5, 1.2m),
new Employee("Mike", "Davis", 45000),
new Manager("Emma", "Wilson", 95000, 12, 1.5m)
};
decimal totalBonus = 0;
foreach (var employee in employees)
{
// Calls the correct CalculateBonus for each type
totalBonus += employee.CalculateBonus();
employee.DisplayInfo();
Console.WriteLine();
}
Console.WriteLine($"Total company bonus budget: ${totalBonus:N2}");
The list holds Employee references, but some point to Manager objects. When you call CalculateBonus or DisplayInfo, C# dispatches to the correct override automatically. This is runtime polymorphism—the method called depends on the actual object type, not the reference type.
Using Abstract Classes for Contracts
Abstract classes let you define methods that derived classes must implement while providing shared code they can inherit. You can't instantiate abstract classes directly—they exist only to be inherited. This forces a contract while still allowing code reuse.
namespace Geometry;
public abstract class Shape
{
public string Name { get; set; }
public string Color { get; set; }
protected Shape(string name, string color)
{
Name = name;
Color = color;
}
// Abstract method - must be implemented by derived classes
public abstract double CalculateArea();
// Abstract method for perimeter
public abstract double CalculatePerimeter();
// Concrete method - shared implementation
public virtual void Display()
{
Console.WriteLine($"{Color} {Name}");
Console.WriteLine($"Area: {CalculateArea():F2}");
Console.WriteLine($"Perimeter: {CalculatePerimeter():F2}");
}
}
public class Circle : Shape
{
public double Radius { get; set; }
public Circle(double radius, string color)
: base("Circle", color)
{
Radius = radius;
}
public override double CalculateArea()
{
return Math.PI * Radius * Radius;
}
public override double CalculatePerimeter()
{
return 2 * Math.PI * Radius;
}
}
public class Rectangle : Shape
{
public double Width { get; set; }
public double Height { get; set; }
public Rectangle(double width, double height, string color)
: base("Rectangle", color)
{
Width = width;
Height = height;
}
public override double CalculateArea()
{
return Width * Height;
}
public override double CalculatePerimeter()
{
return 2 * (Width + Height);
}
}
Abstract methods have no implementation in the base class—they're pure contracts. Circle and Rectangle must implement CalculateArea and CalculatePerimeter or they won't compile. The Display method is concrete, so all shapes inherit it automatically. This combination of contract enforcement and code sharing is what makes abstract classes powerful.
Preventing Inheritance with Sealed
The sealed keyword prevents other classes from inheriting from yours. Use it when your class wasn't designed for extension or when inheritance would break assumptions your class relies on. Sealing also enables compiler optimizations since the runtime knows no derived classes exist.
namespace Security;
public sealed class EncryptionService
{
private readonly string _encryptionKey;
public EncryptionService(string key)
{
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Encryption key required");
_encryptionKey = key;
}
public string Encrypt(string plainText)
{
// Encryption logic that assumes _encryptionKey is always valid
// Inheritance could break this assumption
return Convert.ToBase64String(
System.Text.Encoding.UTF8.GetBytes(plainText + _encryptionKey));
}
public string Decrypt(string cipherText)
{
var bytes = Convert.FromBase64String(cipherText);
var decoded = System.Text.Encoding.UTF8.GetString(bytes);
return decoded.Replace(_encryptionKey, "");
}
}
// This won't compile - EncryptionService is sealed
// public class CustomEncryption : EncryptionService { }
Sealing EncryptionService prevents someone from creating a derived class that might bypass security checks or break encryption assumptions. This is a deliberate design choice—not all classes should be extensible. Many .NET framework classes like String and ValueTuple are sealed for similar reasons.
Gotchas and Solutions
Calling virtual methods in constructors breaks initialization order: When a base constructor calls a virtual method, the derived override runs before the derived constructor body executes. Derived fields aren't initialized yet, causing NullReferenceException. Solution: avoid virtual calls in constructors; use initialization methods called after construction.
Forgetting override keyword creates method hiding: If you define a method with the same signature as a base method but don't use override, you create a completely separate method. Polymorphism breaks because calls through base references hit the base method. Always use override when you intend polymorphic behavior and use new when you deliberately want hiding.
Deep hierarchies become maintenance nightmares: More than 2-3 levels of inheritance makes code hard to understand and change. Each level adds cognitive load and potential for bugs. Solution: prefer composition or extract shared behavior into helper classes rather than adding more inheritance levels.
Try It Yourself
Build a simple shape hierarchy demonstrating inheritance, virtual methods, and polymorphism. You'll create an abstract base class and several derived types that override behavior.
1. dotnet new console -n InheritanceDemo
2. cd InheritanceDemo
3. Replace Program.cs with the code below
4. dotnet run
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
// Demonstrating inheritance and polymorphism
var shapes = new List<Shape>
{
new Circle(5),
new Rectangle(4, 6),
new Triangle(3, 4, 5)
};
Console.WriteLine("Shape Calculations:\n");
foreach (var shape in shapes)
{
shape.Display();
Console.WriteLine();
}
abstract class Shape
{
public abstract double CalculateArea();
public abstract string GetShapeType();
public virtual void Display()
{
Console.WriteLine($"Shape: {GetShapeType()}");
Console.WriteLine($"Area: {CalculateArea():F2} square units");
}
}
class Circle : Shape
{
public double Radius { get; }
public Circle(double radius) => Radius = radius;
public override double CalculateArea() => Math.PI * Radius * Radius;
public override string GetShapeType() => "Circle";
public override void Display()
{
base.Display();
Console.WriteLine($"Radius: {Radius:F2}");
}
}
class Rectangle : Shape
{
public double Width { get; }
public double Height { get; }
public Rectangle(double width, double height)
{
Width = width;
Height = height;
}
public override double CalculateArea() => Width * Height;
public override string GetShapeType() => "Rectangle";
public override void Display()
{
base.Display();
Console.WriteLine($"Dimensions: {Width:F2} x {Height:F2}");
}
}
class Triangle : Shape
{
public double A { get; }
public double B { get; }
public double C { get; }
public Triangle(double a, double b, double c)
{
A = a;
B = b;
C = c;
}
public override double CalculateArea()
{
// Heron's formula
double s = (A + B + C) / 2;
return Math.Sqrt(s * (s - A) * (s - B) * (s - C));
}
public override string GetShapeType() => "Triangle";
public override void Display()
{
base.Display();
Console.WriteLine($"Sides: {A}, {B}, {C}");
}
}
Shape Calculations:
Shape: Circle
Area: 78.54 square units
Radius: 5.00
Shape: Rectangle
Area: 24.00 square units
Dimensions: 4.00 x 6.00
Shape: Triangle
Area: 6.00 square units
Sides: 3, 4, 5
The example shows polymorphism in action—each shape calculates its area differently, but the calling code treats them uniformly through the Shape base class reference.
From Basic to Production-Ready
The initial inheritance examples are functional but naive. Production code needs better error handling, immutability, and thoughtful design. Let's refactor the Employee hierarchy through several improvements.
Step 1: Add validation and immutability. Make properties init-only or readonly where possible. Validate in constructors to ensure objects are always in valid states. This prevents bugs from partially initialized objects or invalid state changes.
Step 2: Introduce proper exception handling. Don't let invalid bonuses or calculations fail silently. Throw meaningful exceptions with clear messages. In CalculateBonus, check for negative results or overflow conditions that indicate data problems.
Step 3: Extract interfaces for testability. Create IEmployee interface with the contract methods. Now you can mock employees in tests without needing full inheritance hierarchies. This also lets you swap inheritance for composition if requirements change later—code depending on IEmployee won't break.
The final state is an Employee hierarchy that's robust, testable, and maintainable. Properties are immutable after construction. Validation happens upfront. Interfaces enable testing and future refactoring. The benefits compound: fewer bugs, easier tests, safer changes.