Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 6

Composition vs. Inheritance

Recommended resources

Hello. In the last couple of lessons, we've focused on how custom types interact, first through overloaded operators and then through custom conversion operators. These techniques help you design expressive and intuitive APIs at the micro-level.

Today, we are zooming out to look at a macro-level design principle that shapes the entire architecture of your object-oriented systems. Our learning objective is to choose between composition over inheritance to create flexible and maintainable designs. This isn't just about code syntax; it's about a fundamental mindset shift from "what is it?" (inheritance) to "what can it do?" (composition). Mastering this will enable you to build systems that are far more resilient to the inevitable changes in business requirements.

1. The Core Idea: "Is-A" vs. "Has-A"

At the heart of this principle are two ways of looking at relationships between objects:

  • Inheritance models an "is-a" relationship. This is a classification. For example, a Car is a Vehicle. This creates a tight, hierarchical bond. The child class implicitly gets all the public and protected behavior and state of the parent.
  • Composition models a "has-a" relationship. This is about assembly. For example, a Car has an Engine and has Wheels. The Car object is built by combining, or composing, other independent objects. It contains instances of other objects and delegates tasks to them.

Inheritance is a powerful tool for code reuse, but when overused, it can lead to rigid and fragile designs. The principle "favor composition over inheritance" suggests that you should default to the more flexible "has-a" model unless a strong, permanent "is-a" relationship truly exists.


2. When Inheritance Breaks Down

As a full-stack developer, you've likely encountered systems where an initially clean class hierarchy became a maintenance nightmare. This often happens when the "is-a" relationship is stretched too far.

Inheritance vs Interfaces in C#: Object Oriented Programming

Tim Corey's video "Inheritance vs Interfaces in C#" provides a fantastic, practical demonstration of how a seemingly logical inheritance structure can fall apart. He walks through building a system for a vehicle rental agency.

First, watch the section from the initial design, where a RentalCar class is refactored into a RentalVehicle base class. This seems like a perfectly logical start. Next, see how a RentalTruck is easily added to the system from this segment. At this point, the inheritance model seems to be working well. Now, pay close attention to what happens when a RentalSailboat is introduced from the sailboat problem. A sailboat doesn't have an engine, but it's forced to inherit StartEngine() and StopEngine() methods. The only "solution" is to override them and throw an exception, a clear sign that the abstraction is broken.

This "sailboat problem" perfectly illustrates the classic pitfalls of over-relying on inheritance:

  • The Fragile Base Class Problem: If you were to add a Refuel() method to RentalVehicle, every single derived class, including RentalSailboat, would be affected, often in ways that don't make sense.
  • Forced Implementations: The RentalSailboat is forced to acknowledge methods that are irrelevant to it, leading to confusing code and potential runtime errors.
  • Inflexibility: C# does not support multiple inheritance. What if you wanted to model an amphibious vehicle that is both a LandVehicle and a WaterVehicle? You can't inherit from both.

As requirements evolve, these simple inheritance trees can lead to a "combinatorial explosion" where you need a new class for every possible combination of features, leading to massive code duplication.


3. Solving the Problem with Composition

Let's redesign a system using composition to see how it solves these issues. Instead of thinking about what an object is, we'll think about what it does by breaking its behaviors into small, focused, and interchangeable components.

Composition Over Inheritance: Building Flexible C# Applications

The article "Composition Over Inheritance" provides a stellar employee management system example that contrasts the two approaches.

First, review the section "The Inheritance Approach (Bad Example)". This presents a rigid hierarchy for employees (FullTimeEmployee, PartTimeEmployee, etc.). Read through this example code and notice how it runs into the same issues we saw with the vehicle example, especially when trying to model a Contractor. Now, carefully study the refactored design in "The Composition Approach (Good Example)". Focus on how behaviors like calculating bonuses and managing benefits are extracted into interfaces (ICompensationCalculator, IBenefitsPackage). Read from the beginning of the code to see how a generic Employee class is now constructed by being "composed" of these different behavioral components.

In the compositional approach, the Employee class is no longer a FullTimeEmployee or a PartTimeEmployee. It's simply an Employee that has a compensation strategy, has a benefits package, and has a work schedule.

This design offers tremendous benefits:

  • Flexibility: You can now create any combination of employee you need without creating new classes. Need a full-time employee who is paid hourly? No problem: just pass in a FullTimeSchedule and an HourlyCompensation object.
  • Loose Coupling: You can change the bonus calculation logic for salaried employees by modifying only the SalariedCompensation class. The Employee class and all other components are completely unaffected.
  • Testability: Each component (NoBonus, StandardBenefits, etc.) can be unit-tested in isolation, which is much simpler than testing a complex inheritance chain.

This approach also allows for behaviors to be changed at runtime, which is impossible with inheritance (since an object's type is fixed at compile time).

This C# snippet shows runtime flexibility. The `Weapon_Base` class isn't tied to one damage type. Instead, it holds a reference to an `IDoDamage` object. The `SetDamageType` method allows this behavior to be changed dynamically, even after the weapon has been created, demonstrating a key advantage of composition.

4. When to Use Inheritance vs. Composition

This principle is "favor" composition, not "always use" composition. Inheritance still has an important, albeit more limited, role.

Composition Over Inheritance: Building Flexible C# Applications

The same article provides an excellent summary of when to choose each approach.

Please read the section titled "When to Use Inheritance vs Composition". Pay close attention to the criteria listed for both inheritance and composition.

To summarize the key decision points:

Use Inheritance When:

  • You have a true, stable "is-a" relationship that satisfies the Liskov Substitution Principle (i.e., you can substitute a child class for a parent class anywhere without causing issues). A Cat is an Animal. A List<T> is an IEnumerable<T>.
  • You are extending a framework that is designed around inheritance. For instance, in ASP.NET Core, your controllers inherit from ControllerBase.
  • You want to use the Template Method design pattern, where a base class defines the skeleton of an algorithm and derived classes override specific steps.

Use Composition When:

  • You need flexibility and want to combine behaviors in various ways.
  • You need to change behavior at runtime.
  • You want to avoid the rigidity of a single inheritance chain and need something akin to multiple inheritance.
  • The relationship is more accurately described as "has-a", "uses-a", or "is-implemented-in-terms-of".

Many powerful design patterns, like Strategy, Decorator, and Adapter, are built upon the principle of composition. The employee compensation example is a classic implementation of the Strategy pattern.

This UML class diagram illustrates the Strategy pattern, a common application of composition. Instead of different `Duck` subtypes inheriting a `fly()` method, the `Duck` class is composed with a `Flyable` interface. This allows the flying behavior (`FlyWithWings`, `CannotFly`) to be selected and even changed independently of the duck itself.

Conclusion

Today we've explored one of the most important principles in object-oriented design. By shifting your default thinking from inheritance to composition, you move from creating rigid hierarchies to building flexible assemblies of independent, reusable components.

Here are the key takeaways:

  • "Is-A" vs. "Has-A": Inheritance models "is-a" relationships, creating tight coupling. Composition models "has-a" relationships, promoting loose coupling and flexibility.
  • Flexibility is Key: Composition allows you to mix and match behaviors, change them at runtime, and avoid the problems of rigid class hierarchies and the lack of multiple inheritance in C#.
  • It's a Guideline, Not a Rule: Inheritance is still the correct choice for true "is-a" relationships and for extending frameworks that are designed around it.
  • Think in Components: Break down complex objects into smaller, single-responsibility behaviors that can be composed together. This naturally leads to more maintainable, testable, and scalable code.

In our next lesson, we will synthesize what we've learned about records, sealed classes, interfaces, and composition. We will focus on the decision-making process to select the appropriate tool (abstract class, interface, or record) for a given API design scenario, solidifying your ability to make expert-level design choices.