TL;DR:
- Inheritance isn’t evil, it’s appropriate when you need polymorphism without runtime behavior swapping
- Use inheritance for stable “is-a” relationships where behavior is intrinsic to the type
- Composition adds unnecessary complexity when the behavior won’t change at runtime
- Good examples for inheritance include domain models, DTO hierarchies, and static polymorphic behavior
- Design principles are guidelines, not rules, the right solution depends on your specific context
- Don’t overengineer with composition when inheritance provides a simpler, clearer solution
You’ve probably heard the advice: “Favor composition over inheritance.” But does that mean inheritance is always bad? No, and here’s when it still shines.
As a principle, “favor composition over inheritance” has become almost dogmatic in modern software design. Many developers interpret this as “inheritance is evil, avoid it at all costs.” But like most principles in software development, the reality has nuance. Inheritance is not obsolete; it has its place when polymorphic behavior is needed but runtime flexibility isn’t.
What I Mean by “Polymorphic but No Swapping”
This might sound contradictory at first. After all, isn’t polymorphism all about flexibility? Not exactly. What I’m referring to is a specific scenario:
- You need polymorphism to handle multiple types through a base type (classic OOP)
- But your implementations are stable and won’t change at runtime
- The behavior is intrinsic to the type itself
Consider file parsers for different formats, document types in a system, or enums with behavior. In these cases, the type and its behavior are tightly coupled by design, and that’s perfectly fine.
A Real-World Example in C#
Here’s a simple, common scenario, a document processing system:
public abstract class Document
{
public abstract void Print();
}
public class Invoice : Document
{
public override void Print() => Console.WriteLine("Printing Invoice...");
}
public class Report : Document
{
public override void Print() => Console.WriteLine("Printing Report...");
}
public class DocumentProcessor
{
public void Process(Document document) => document.Print();
}
This design is clean and intuitive. An invoice is a document. A report is a document. Each knows how to print itself. The document processor doesn’t care which specific document it’s handling, it just works with the abstraction.
Key Point: You don’t need to swap Invoice
or Report
behavior dynamically. An Invoice will always print like an Invoice. A Report will always print like a Report. Inheritance keeps the design simple and reflects the natural “is-a” relationship.
Why Not Use Composition Here?
Sure, you could introduce a composition-based approach:
public interface IPrintBehavior
{
void Print();
}
public class InvoicePrinter : IPrintBehavior
{
public void Print() => Console.WriteLine("Printing Invoice...");
}
public class ReportPrinter : IPrintBehavior
{
public void Print() => Console.WriteLine("Printing Report...");
}
public class Document
{
private readonly IPrintBehavior _printBehavior;
public Document(IPrintBehavior printBehavior)
{
_printBehavior = printBehavior;
}
public void Print() => _printBehavior.Print();
}
But what have we gained? More code, more complexity, and more indirection, all without any tangible benefit. The behavior isn’t going to change at runtime, so why add the extra layer of abstraction? This is a classic case of overengineering, which is also bad design.
Guideline, Not a Rule
It’s important to remember that “favor composition over inheritance” is a guideline, not a strict rule. Here’s my principle:
Favor composition when you need flexibility and runtime swapping. This is when the Strategy pattern and other compositional approaches shine.
Prefer inheritance when the type hierarchy is stable and adding abstraction would only complicate the design without providing actual benefits.
The goal of software design isn’t to blindly follow rules, it’s to create maintainable, understandable, and correct code.
Common Scenarios Where Inheritance is Fine
Here are some scenarios where inheritance often makes perfect sense:
Domain models with fixed hierarchies: When your domain naturally has an “is-a” relationship that won’t change. For example, a
SavingsAccount
is always anAccount
.public abstract class Account { public decimal Balance { get; protected set; } public abstract void ApplyInterest(); } public class SavingsAccount : Account { public decimal InterestRate { get; set; } public override void ApplyInterest() { Balance += Balance * InterestRate; } } public class CheckingAccount : Account { public override void ApplyInterest() { // Checking accounts might have different interest rules if (Balance > 1000) Balance += Balance * 0.001m; } }
The “is-a” relationship is clear and stable. A savings account will always be an account, and this relationship won’t change during the lifetime of your application.
DTO hierarchies: When using a serializer that supports polymorphism, inheritance can make for cleaner serialization/deserialization of related data structures.
[JsonPolymorphic] public abstract class NotificationDto { public string Id { get; set; } public DateTime Timestamp { get; set; } } [JsonDerivedType(typeof(EmailNotificationDto), typeDiscriminator: "email")] public class EmailNotificationDto : NotificationDto { public string EmailAddress { get; set; } public string Subject { get; set; } } [JsonDerivedType(typeof(SmsNotificationDto), typeDiscriminator: "sms")] public class SmsNotificationDto : NotificationDto { public string PhoneNumber { get; set; } }
Here, modern JSON serializers can handle the type discriminator automatically, which means you can serialize/deserialize notification DTOs without writing custom conversion logic.
Static polymorphism like enums with behavior: While many developers use composition for this case too (especially in Java and C#), simple inheritance hierarchies can sometimes be more straightforward.
public abstract class PaymentMethod { public abstract decimal CalculateFee(decimal amount); } public class CreditCardPayment : PaymentMethod { public override decimal CalculateFee(decimal amount) { return amount * 0.03m; // 3% fee } } public class BankTransferPayment : PaymentMethod { public override decimal CalculateFee(decimal amount) { return amount * 0.01m; // 1% fee } } // Usage public void ProcessPayment(PaymentMethod method, decimal amount) { decimal fee = method.CalculateFee(amount); decimal total = amount + fee; // Process payment... }
In many languages, this could be modeled as an enum with behavior. The key is that we don’t expect to dynamically swap the fee calculation algorithm at runtime, it’s intrinsic to the payment method type.
My Opinion as a Senior Developer
As a senior developer, I’ve learned that no pattern is good or bad in isolation. Design choices are trade-offs. Inheritance is simpler, clearer, and sufficient in some cases, especially when runtime swapping is irrelevant.
The real skill in software design isn’t memorizing patterns, it’s understanding when to use them. Sometimes the elegant solution is the simplest one, and that might just be inheritance.
Related Posts
- How Polymorphism Makes C# Code Flexible: Real-World Examples and Best Practices
- Polymorphism in C#: How Template Method, Strategy, and Visitor Patterns Make Your Code Flexible
- Object-Oriented Programming: Core Principles and C# Implementation
- Prefer Interfaces Over Abstract Classes in C#: Build Flexible, Testable, and Maintainable Code
- Method Overloading vs Overriding in C#: Key Differences and Examples