Composition Over Inheritance

To understand Composition over Inheritance, let's first break down the concepts of Composition and Inheritance with definitions and examples.

Inheritance

Inheritance is a mechanism in object-oriented programming where a new class (subclass or child) derives properties and behaviours (methods) from an existing class (superclass or parent). It establishes an "is-a" relationship between classes.

  • Models an "is-a" relationship.

  • Allows code reuse by inheriting methods and properties from the parent.

  • Changes in the parent class can affect all subclasses.

class Animal
{
    public function eat(): string
    {
        return "This animal eats.";
    }
}

class Dog extends Animal
{
    public function bark(): string
    {
        return "The dog barks.";
    }
}

// A Dog IS An Animal.
$dog = new Dog();
echo $dog->eat(); // Output: This animal eats.
echo $dog->bark(); // Output: The dog barks.

Whilst not inherently a bad thing, Inheritance creates a tight coupling between the parent and child classes.

If you need significant changes in one class, it can ripple through the hierarchy, making maintenance harder. Overusing inheritance can lead to deep and complex hierarchies, known as the fragile base class problem.

Composition

Composition is a design principle where a class is made up of one or more objects from other classes. Instead of inheriting behaviours, a class delegates tasks to its composed objects. It establishes a "has-a" relationship.

  • Models a "has-a" relationship.

  • Promotes loose coupling by combining smaller, reusable components.

  • Enables dynamic behaviour by switching out components.

class Engine
{
    public function start(): string
    {
        return "Engine starts.";
    }
}

class Car
{
    public function __construct(private Engine $engine)
    {

    }

    public function drive()
    {
        return $this->engine->start() . " Car is driving.";
    }
}

// Car HAS An Engine
$engine = new Engine();
$car = new Car($engine);
echo $car->drive(); // Output: Engine starts. Car is driving.

In this example, a Car has an Engine, but the two are loosely coupled. You can replace the engine without changing the Car class.

Why Favour Composition Over Inheritance?

  • Avoids Fragile Base Class Problem:
    Changes in a base class can break all child classes. Composition avoids this by separating concerns.

  • Encourages Modularity:
    You can compose behaviours dynamically by combining different objects.

  • Improves Code Maintainability:
    Classes are smaller, more focused, and easier to test or modify.

  • Decouples Dependencies:
    Composition allows swapping components without affecting the entire system.

By combining behaviours using composition, we achieve greater flexibility and reusability, which is why many modern programming practices and frameworks favour composition over inheritance.

The Coffee Shop Problem

Inheritance and composition are both tools in object-oriented programming. Like any tool, they have their purposes, but misuse can lead to messy, inflexible code. While composition is often favoured for its flexibility, this doesn’t mean inheritance is bad—it simply has a narrower scope of ideal use cases. The real problem lies in how we use these tools, not the tools themselves.

Let’s explore the Coffee Shop Problem, where inheritance becomes problematic, and then refactor it using composition (with a sprinkle of inheritance).

Lets start off with a basic Coffee class, which has two methods, one to get the description and another to get the cost.

class Coffee
{
    public function getDescription(): string
    {
        return "Generic Coffee";
    }

    public function getCost(): float
    {
        return 2.00;
    }
}

Say we’d like to accommodate an Espresso, which is a child of Coffee but we also need an EspressoWithMilk and an EspressoWithSugar, you might end up with something like this?

class Espresso extends Coffee
{
    public function getDescription(): string
    {
        return "Espresso";
    }

    public function getCost(): float
    {
        return 2.50;
    }
}

class EspressoWithMilk extends Espresso
{
    public function getDescription(): string
    {
        return parent::getDescription() . " with Milk";
    }

    public function getCost(): float
    {
        return parent::getCost() + 0.50;
    }
}

class EspressoWithMilkAndSugar extends EspressoWithMilk
{
    public function getDescription(): string
    {
        return parent::getDescription() . " and Sugar";
    }

    public function getCost(): float
    {
        return parent::getCost() + 0.20;
    }
}

But what would happen if we then needed an Americano, Latte, or a Hot Chocolate. Maybe the Hot Chocolate has tonnes of different options such as extra marshmallows, whipped cream, or flavoured syrups.

Suddenly, our class hierarchy spirals out of control. For every new drink type or combination of ingredients, we need another subclass. This is the dreaded class explosion problem.

What happens if we need to change the price of milk across several subclasses? Well, the rigidity of this inheritance-based approach rears its ugly head. Since the price of milk is hardcoded in each subclass (e.g., EspressoWithMilk, LatteWithMilkAndSugar), we would need to hunt down every subclass where milk is used and manually update the cost.

This not only increases the risk of inconsistent updates (what if you miss one class?) but also violates the DRY principle (Don’t Repeat Yourself). The more subclasses you have, the more tedious and error-prone this process becomes.

For example:

  • Update EspressoWithMilk to add $0.60 for milk instead of $0.50.

  • Update AmericanoWithMilk for the same.

  • Update LatteWithMilkAndSugar, etc.

If you have dozens of subclasses, this task can quickly spiral into a nightmare of repetitive edits, all while praying you don’t miss one.

class EspressoWithMilk extends Coffee
{
    public function getDescription(): string
    {
        return "Espresso with Milk";
    }

    public function getCost(): float
    {
        return 2.50 + 0.50; // Milk costs $0.50
    }
}

class AmericanoWithMilk extends Coffee
{
    public function getDescription(): string
    {
        return "Americano with Milk";
    }

    public function getCost(): float
    {
        return 2.30 + 0.50; // Milk costs $0.50
    }
}

class LatteWithMilkAndSugar extends Coffee
{
    public function getDescription(): string
    {
        return "Latte with Milk and Sugar";
    }

    public function getCost(): float
    {
        return 3.00 + 0.50 + 0.20; // Milk $0.50, Sugar $0.20
    }
}

You see the problem? Inheritance is going to cause a mess here. For every new combination of drinks and ingredients, we’re forced to create a new subclass. If we need to adjust something—like the price of milk—we have to update multiple subclasses, leaving room for inconsistency and bugs. This approach is rigid, repetitive, and difficult to maintain.

The Coffee Shop Solution

To fix this, we can use composition. Instead of hardcoding ingredients and their costs in subclasses, we’ll separate the concerns into smaller, reusable components. Each drink remains a single class, and we dynamically compose the final product by attaching ingredients to the base drink.

Here’s how we’ll tackle it:

  1. Base Drink Class: Represents a basic drink class.

  2. Ingredients: Modular classes for extras like milk, sugar, whipped cream, etc.

  3. Composition: Add ingredients to the drink at runtime, rather than in the class hierarchy.

Lets’s define the base Drink class.

class Drink
{
    private array $ingredients = [];

    public function __construct(
        private string $description, 
        private float $baseCost
    )
    {
        //
    }

    public function addIngredient(Ingredient $ingredient): self
    {
        $this->ingredients[] = $ingredient;

        return $this;
    }

    public function getDescription(): string
    {
        $descriptions = array_map(fn(Ingredient $ingredient) => $ingredient->getDescription(), $this->ingredients);

        return $this->description . (empty($descriptions) ? "" : " with " . implode(", ", $descriptions));
    }

    public function getCost(): float
    {
        $costs = array_reduce($this->ingredients, fn($sum, Ingredient $ingredient) => $sum + $ingredient->getCost(), 0);
        return $this->baseCost + $costs;
    }
}

Next, lets create the Ingredient subclasses, lets also use an abstract class to eliminate boilerplate (duplicated code) throughout these sub classes.

interface Ingredient
{
    public function getDescription(): string;
    public function getCost(): float;
}

abstract class AbstractIngredient implements Ingredient
{
    protected string $description;
    protected float $cost;

    public function getDescription(): string
    {
        return $this->description;
    }

    public function getCost(): float
    {
        return $this->cost;
    }
}

class Milk extends AbstractIngredient
{
    protected string $description = 'Milk';
    protected float $cost = 0.5;
}

class Sugar extends AbstractIngredient
{
    protected string $description = 'Sugar';
    protected float $cost = 0.25;
}

class WhippedCream extends AbstractIngredient
{
    protected string $description = 'Whipped Cream';
    protected float $cost = 0.85;
}

class FlavouredSyrup extends AbstractIngredient
{
    protected string $description = 'Flavoured Syrup';
    protected float $cost = 0.45;

    public function __construct(string $type) 
    {
        $this->description .= " " . $type;
    }
}

Now, using the Drink class and the Ingredient subclasses, we can dynamically assemble any drink.

// Espresso
$espresso = new Drink("Espresso", 2.50);
$espressoWithMilk = $espresso->addIngredient(new Milk());

// Americano
$americano = new Drink("Americano", 2.30);
$americanoWithMilkAndSugar = $americano
    ->addIngredient(new Milk())
    ->addIngredient(new Sugar());

// Hot Chocolate
$hotChocolate = new Drink("Hot Chocolate", 3.50);
$deluxeHotChocolate = $hotChocolate
    ->addIngredient(new Milk())
    ->addIngredient(new WhippedCream())
    ->addIngredient(new FlavouredSyrup("Caramel"))
    ->addIngredient(new FlavouredSyrup("Vanilla"));

Why This Works

  1. Centralised Logic: The Ingredient abstract class ensures that all shared behaviour (e.g., description and cost) is defined in one place.

  2. Flexibility: Adding a new ingredient, like "Oat Milk," is as simple as creating a new subclass of Ingredient with the appropriate description and cost.

  3. Dynamic Composition: Drinks are composed dynamically, eliminating the rigid class hierarchy of inheritance-based solutions.

  4. Scalability: If the price of milk changes, you update it in one place—the Milk class.

This approach keeps the system clean, modular, and easy to extend or modify but it’s also worth mentioning that while we’ve largely used composition to manage the ingredients and drink assembly, inheritance still plays an important role here in the structure of the ingredients.

We’ve leveraged inheritance to define an abstract Ingredient class, from which all specific ingredients (like Milk, Sugar, WhippedCream, etc.) inherit. This allows us to centralise shared functionality, such as the getDescription() and getCost() methods, while letting each subclass define its unique behaviour.

In this case, inheritance ensures that every ingredient shares the same base structure while maintaining flexibility for individual customisations (like description and cost). Without inheritance, we’d have to manually define the description and cost methods for each ingredient separately, leading to unnecessary duplication of code.

In summary, we've used composition for dynamic drink construction, and inheritance for structuring common ingredient behaviours. This hybrid approach gives us the benefits of both techniques while avoiding their potential pitfalls.

Want to play about with it? Check out the code example on Github.

The Grumpy Dev is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.