Design Patterns & Practices  

Composition Over Inheritance: Building Flexible C# Applications

Hey fellow developers! đź‘‹ Today, we're diving into one of the most important design principles in object-oriented programming: Composition Over Inheritance. If you've been writing C# code for a while, you've probably heard this advice countless times, but do you really understand why composition is preferred and when to use it?

Let me share a story from my early coding days. I was building an e-commerce application and enthusiastically created a beautiful inheritance hierarchy: Product → PhysicalProduct → ElectronicProduct → SmartphoneProduct. It looked elegant on paper! But then business requirements changed (as they always do), and I needed products that could be both physical AND digital (like a book with an ebook bundle). My entire inheritance tree came crashing down. That painful refactoring taught me the value of composition.

What is Composition Over Inheritance?

Composition Over Inheritance is a design principle that suggests you should build complex functionality by combining simple, focused objects rather than creating deep inheritance hierarchies. Instead of saying "A Car is a Vehicle," you say "A Car has an Engine, has a Transmission, and has Wheels."

Think of it like building with LEGO blocks. Inheritance is like creating specialised blocks that can only connect in specific ways. Composition is like having standard blocks you can snap together in any combination to create whatever you need.

Interview-Ready Definition

"Composition Over Inheritance is a design principle in object-oriented programming that recommends achieving code reuse and polymorphism through object composition (has-a relationship) rather than class inheritance (is-a relationship). This approach creates more flexible, maintainable code by assembling functionality from independent components instead of inheriting behaviour from parent classes."

Understanding the Problem with Inheritance

Before we appreciate composition, let's understand why excessive inheritance causes problems. Inheritance isn't inherently bad—it's a fundamental OOP concept—but it's often overused.

The Fragile Base Class Problem: When you change a base class, all derived classes are affected. This tight coupling makes your codebase brittle and hard to maintain.

Deep Inheritance Hierarchies: As your application grows, inheritance trees become deeper and more complex. Understanding the behaviour of a class requires tracing through multiple parent classes.

Limited Flexibility: C# (like Java) doesn't support multiple inheritance. If you need functionality from two different base classes, you're stuck.

Inappropriate Relationships: Sometimes we force inheritance where it doesn't make semantic sense, just to reuse code. This violates the Liskov Substitution Principle.

Let's see a real example of how inheritance can go wrong.

Real-World Example: Employee Management System

Imagine you're building an employee management system. Your first instinct might be to use inheritance to model different employee types.

❌ The Inheritance Approach (Bad Example)

// Base class
public class Employee
{
    public string Name { get; set; }
    public string Email { get; set; }
    public decimal Salary { get; set; }
    
    public virtual decimal CalculateBonus()
    {
        return Salary * 0.10m; // 10% bonus
    }
    
    public virtual void GenerateReport()
    {
        Console.WriteLine($"Employee Report for {Name}");
        Console.WriteLine($"Email: {Email}");
        Console.WriteLine($"Salary: {Salary:C}");
    }
}

// Full-time employee with benefits
public class FullTimeEmployee : Employee
{
    public decimal HealthInsurance { get; set; }
    public decimal RetirementContribution { get; set; }
    
    public override decimal CalculateBonus()
    {
        return Salary * 0.15m; // 15% bonus for full-time
    }
    
    public override void GenerateReport()
    {
        base.GenerateReport();
        Console.WriteLine($"Health Insurance: {HealthInsurance:C}");
        Console.WriteLine($"Retirement: {RetirementContribution:C}");
    }
}

// Part-time employee
public class PartTimeEmployee : Employee
{
    public int HoursPerWeek { get; set; }
    
    public override decimal CalculateBonus()
    {
        return Salary * 0.05m; // 5% bonus for part-time
    }
    
    public override void GenerateReport()
    {
        base.GenerateReport();
        Console.WriteLine($"Hours Per Week: {HoursPerWeek}");
    }
}

// Contractor - now we have a problem!
public class Contractor : Employee
{
    public DateTime ContractEndDate { get; set; }
    
    public override decimal CalculateBonus()
    {
        // Contractors don't get bonuses, but we're forced to override
        return 0;
    }
    
    public override void GenerateReport()
    {
        base.GenerateReport();
        Console.WriteLine($"Contract Ends: {ContractEndDate:d}");
    }
}

Problems with this approach

  • Inappropriate inheritance: Contractors inherit Salary property, but they might work on hourly rates instead

  • Forced implementation: Contractors must override CalculateBonus() even though bonuses don't apply to them

  • Inflexibility: What if you need an employee who is both full-time AND a contractor (consulting arrangement)? The hierarchy breaks down

  • Tight coupling: Changes to the Employee base class ripple through all derived classes

  • Violation of Liskov Substitution Principle: A Contractor can't truly substitute an Employee in all contexts

âś… The Composition Approach (Good Example)

Let's redesign this system using composition. We'll break down employee features into independent, composable components.

// Compensation strategies
public interface ICompensationCalculator
{
    decimal CalculateBonus(decimal basePay);
}

public class SalariedCompensation : ICompensationCalculator
{
    private readonly decimal _bonusPercentage;
    
    public SalariedCompensation(decimal bonusPercentage)
    {
        _bonusPercentage = bonusPercentage;
    }
    
    public decimal CalculateBonus(decimal basePay)
    {
        return basePay * _bonusPercentage;
    }
}

public class HourlyCompensation : ICompensationCalculator
{
    public decimal CalculateBonus(decimal basePay)
    {
        // Hourly workers get fixed bonus
        return 500m;
    }
}

public class NoBonus : ICompensationCalculator
{
    public decimal CalculateBonus(decimal basePay)
    {
        return 0;
    }
}

// Benefits package
public interface IBenefitsPackage
{
    void DisplayBenefits();
}

public class StandardBenefits : IBenefitsPackage
{
    public decimal HealthInsurance { get; set; }
    public decimal RetirementContribution { get; set; }
    
    public void DisplayBenefits()
    {
        Console.WriteLine($"Health Insurance: {HealthInsurance:C}");
        Console.WriteLine($"Retirement: {RetirementContribution:C}");
    }
}

public class NoBenefits : IBenefitsPackage
{
    public void DisplayBenefits()
    {
        Console.WriteLine("No benefits package");
    }
}

// Work schedule
public interface IWorkSchedule
{
    void DisplaySchedule();
}

public class FullTimeSchedule : IWorkSchedule
{
    public void DisplaySchedule()
    {
        Console.WriteLine("Full-time: 40 hours/week");
    }
}

public class PartTimeSchedule : IWorkSchedule
{
    public int HoursPerWeek { get; set; }
    
    public void DisplaySchedule()
    {
        Console.WriteLine($"Part-time: {HoursPerWeek} hours/week");
    }
}

public class ContractSchedule : IWorkSchedule
{
    public DateTime ContractEndDate { get; set; }
    
    public void DisplaySchedule()
    {
        Console.WriteLine($"Contract until: {ContractEndDate:d}");
    }
}

// Employee class using composition
public class Employee
{
    public string Name { get; set; }
    public string Email { get; set; }
    public decimal BasePay { get; set; }
    
    private readonly ICompensationCalculator _compensationCalculator;
    private readonly IBenefitsPackage _benefitsPackage;
    private readonly IWorkSchedule _workSchedule;
    
    public Employee(
        string name,
        string email,
        decimal basePay,
        ICompensationCalculator compensationCalculator,
        IBenefitsPackage benefitsPackage,
        IWorkSchedule workSchedule)
    {
        Name = name;
        Email = email;
        BasePay = basePay;
        _compensationCalculator = compensationCalculator;
        _benefitsPackage = benefitsPackage;
        _workSchedule = workSchedule;
    }
    
    public decimal CalculateBonus()
    {
        return _compensationCalculator.CalculateBonus(BasePay);
    }
    
    public void GenerateReport()
    {
        Console.WriteLine($"Employee Report for {Name}");
        Console.WriteLine($"Email: {Email}");
        Console.WriteLine($"Base Pay: {BasePay:C}");
        Console.WriteLine($"Bonus: {CalculateBonus():C}");
        
        Console.WriteLine("\nBenefits:");
        _benefitsPackage.DisplayBenefits();
        
        Console.WriteLine("\nSchedule:");
        _workSchedule.DisplaySchedule();
    }
}

// Usage examples
public class Program
{
    public static void Main()
    {
        // Full-time employee
        var fullTime = new Employee(
            "Alice Johnson",
            "[email protected]",
            75000m,
            new SalariedCompensation(0.15m),
            new StandardBenefits { HealthInsurance = 500m, RetirementContribution = 300m },
            new FullTimeSchedule()
        );
        
        fullTime.GenerateReport();
        Console.WriteLine("\n" + new string('-', 50) + "\n");
        
        // Part-time employee
        var partTime = new Employee(
            "Bob Smith",
            "[email protected]",
            35000m,
            new SalariedCompensation(0.05m),
            new NoBenefits(),
            new PartTimeSchedule { HoursPerWeek = 20 }
        );
        
        partTime.GenerateReport();
        Console.WriteLine("\n" + new string('-', 50) + "\n");
        
        // Contractor
        var contractor = new Employee(
            "Carol Davis",
            "[email protected]",
            50000m,
            new NoBonus(),
            new NoBenefits(),
            new ContractSchedule { ContractEndDate = DateTime.Now.AddMonths(6) }
        );
        
        contractor.GenerateReport();
        Console.WriteLine("\n" + new string('-', 50) + "\n");
        
        // Flexible: Full-time employee with hourly compensation (special case)
        var hybrid = new Employee(
            "David Lee",
            "[email protected]",
            60000m,
            new HourlyCompensation(),
            new StandardBenefits { HealthInsurance = 450m, RetirementContribution = 250m },
            new FullTimeSchedule()
        );
        
        hybrid.GenerateReport();
    }
}

Benefits of the composition approach

  • Flexibility: Mix and match components to create any employee type without modifying existing code

  • No forced implementations: Each component has a clear, focused responsibility

  • Easy to extend: Add new compensation types, benefits, or schedules without touching employee class

  • Loose coupling: Changes to compensation logic don't affect benefits or schedule logic

  • Better testing: Test each component independently with simple unit tests

  • Runtime flexibility: You can even change components at runtime if needed

Key Principles of Composition

1. Has-A Instead of Is-A

Composition models "has-a" relationships (Employee has a compensation calculator), while inheritance models "is-a" relationships (Manager is an Employee). "Has-a" relationships are more flexible and realistic for most scenarios.

2. Favour Small, Focused Interfaces

Each component should have a single, well-defined responsibility. This aligns with the Single Responsibility Principle from SOLID.

3. Depend on Abstractions

Use interfaces or abstract classes for component types. This follows the Dependency Inversion Principle and makes testing easier.

4. Compose at Construction Time

Inject dependencies through constructors or properties, making relationships explicit and dependencies clear.

When to Use Inheritance vs Composition

Composition isn't always the answer. Here's how to decide:

Use Inheritance When

  • True is-a relationship exists: A Dog truly is an Animal

  • Shared behaviour is fundamental: All derived classes need ALL base class functionality

  • Liskov Substitution holds: Derived classes can completely substitutethe base class

  • Extending framework classes: When integrating with frameworks that require inheritance

  • Creating template methods: When using the Template Method design pattern

Use Composition When

  • Behaviour can vary independently: Different combinations of features are needed

  • Runtime flexibility required: Need to change behaviour dynamically

  • Multiple inheritance needed: C# doesn't support it, so use composition

  • Avoiding deep hierarchies: The Inheritance tree is getting too complex

  • Code reuse across unrelated classes: Shared functionality doesn't imply a relationship

Common Composition Patterns in C#

Strategy Pattern

As seen in our compensation calculator example, the Strategy pattern uses composition to encapsulate algorithms and make them interchangeable.

Decorator Pattern

Add responsibilities to objects dynamically by wrapping them with decorator objects.

public interface INotification
{
    void Send(string message);
}

public class BasicNotification : INotification
{
    public void Send(string message)
    {
        Console.WriteLine($"Sending: {message}");
    }
}

public class EmailNotificationDecorator : INotification
{
    private readonly INotification _notification;
    
    public EmailNotificationDecorator(INotification notification)
    {
        _notification = notification;
    }
    
    public void Send(string message)
    {
        _notification.Send(message);
        Console.WriteLine("Also sending via email...");
    }
}

public class SMSNotificationDecorator : INotification
{
    private readonly INotification _notification;
    
    public SMSNotificationDecorator(INotification notification)
    {
        _notification = notification;
    }
    
    public void Send(string message)
    {
        _notification.Send(message);
        Console.WriteLine("Also sending via SMS...");
    }
}

// Usage: Compose multiple decorators
INotification notification = new BasicNotification();
notification = new EmailNotificationDecorator(notification);
notification = new SMSNotificationDecorator(notification);
notification.Send("Important alert!");

Composite Pattern

Treat individual objects and compositions uniformly, creating tree structures of objects.

Practical Tips for Applying Composition

  1. Start with interfaces: Define what components should do before implementing them

  2. Keep components small: Each component should have one clear responsibility

  3. Use dependency injection: Frameworks like .NET's built-in DI container make composition easier

  4. Don't over-engineer: Start simple and refactor to composition when complexity grows

  5. Document relationships: Make it clear what components work together and why

Real-World Benefits

In my projects, switching from inheritance to composition has resulted in:

  • 50% reduction in code changes when requirements evolve

  • Easier onboarding for new developers—smaller, focused components are simpler to understand

  • Better test coverage—testing individual components is straightforward

  • Fewer bugs—changes are localised to specific components

  • Faster feature development—new features often just require new components, not refactoring

Common Pitfalls to Avoid

  1. Too many small classes: Don't create a component for everything—balance is key

  2. Over-abstraction: Don't create interfaces for components that will only have one implementation

  3. Circular dependencies: Be careful that components don't depend on each other

  4. Ignoring performance: Composition adds indirection; for performance-critical code, measure first

Conclusion

Composition Over Inheritance isn't about abandoning inheritance entirely—it's about choosing the right tool for the job. Inheritance has its place for modelling true is-a relationships and extending framework classes. But for most application logic, composition provides the flexibility, maintainability, and testability we need.

Think of composition as building with LEGO blocks: small, reusable pieces that can be combined in countless ways. Inheritance is more like Russian nesting dolls: elegant for specific scenarios but rigid and constraining when your needs change.

The next time you're tempted to create a deep inheritance hierarchy, ask yourself: "Can I achieve this with composition instead?" More often than not, the answer will make your code better.

Key Takeaways

  • Prefer "has-a" relationships over "is-a" relationships for flexibility

  • Use small, focused components with single responsibilities

  • Depend on abstractions (interfaces) for loose coupling

  • Apply composition when behaviour varies independently or needs runtime flexibility

  • Reserve inheritance for true is-a relationships and framework integration

Complete source code examples: GitHub Repository Link

Happy coding, and may your classes be loosely coupled and highly cohesive! 🚀