Contents

SOLID++: Composition Over Inheritance

Introduction

Ever been stuck in a tangled inheritance hierarchy where changing one parent class feels like playing software Jenga? You’re not alone. I found myself in that situation once while writing a font creation library. The rigid hierarchy and fragile base class led to a brittle solution that was difficult to extend and maintain. While inheritance is a powerful feature of object-oriented programming, it’s often not the best tool for the job. Let’s explore a more flexible alternative: composition.

As part of the SOLID++ series, we are exploring principles of object-oriented design beyond the five SOLID principles. Here we’ll discuss the idea of favoring composition over inheritance. First, we’ll look at some different types of composition and then dive into the reasons why it’s often preferred over inheritance. All code examples are written for Ruby 3.1. using endless methods for brevity.

Different types of composition

Before diving into why composition is often better than inheritance, let’s look at seven common ways to compose objects. Each approach has its own strengths and use cases.

1. Has-a aggregation

This is where an object contains a reference to another object, but the contained object can exist independently. Think of a Team that has Players - the Players can exist even if the Team dissolves:

class Player
  attr_reader :name
  def initialize(name) = (@name = name)
end

class Team
  def initialize = (@players = [])
  def add_player(player) = (@players << player)
end

# Usage
t_rex = Player.new("T. Rex")
raptors = Team.new
raptors.add_player(t_rex)

Use this when objects have independent lifecycles and loose coupling is desired.

2. Part-of aggregation

Part-of aggregation is a stronger form of aggregation where the contained object’s lifecycle is tied to the container. This is useful when objects are tightly coupled. Think of a Car and its Engine - the Engine only exists as part of the Car:

class Engine
  def start = "Let's go!"
end

class Car
  def initialize = (@engine = Engine.new)
  def start = @engine.start
end

# Usage
car = Car.new
car.start  # => "Let's go!"

Use this when objects are tightly coupled and the contained object can’t exist without the container.

3. Dependency injection

This is where dependencies are passed in rather than created internally. Useful for creating flexlible, testable code.

class PaymentProcessor
  def initialize(payment_gateway) = (@payment_gateway = payment_gateway)
  def process_payment(amount) = @payment_gateway.charge(amount)
end

# Different gateways can be injected
class StripeGateway
  # This is pseudo-code and the actual API may be different
  def charge(amount) = Stripe::Charge.create(amount: amount)
end

class PayPalGateway
  # This is pseudo-code and the actual API may be different
  def charge(amount) = PayPal::Charge.create(amount: amount)
end

# Usage
processor = PaymentProcessor.new(StripeGateway.new)
processor.process_payment(100)

Use this when you want to decouple components and make them easier to test.

4. Delegation

Ruby has built-in support for delegation with forwardable. Used to forward messages while maintaining encapsulation.

require 'forwardable'

class Writer
  extend Forwardable
  
  def_delegators :@formatter, :format_text
  
  def initialize(formatter) = (@formatter = formatter)
end

class HTMLFormatter
  def format_text(text) = "<p>#{text}</p>"
end

# Usage
html_writer = Writer.new(HTMLFormatter.new)
html_writer.format_text("Hello, world!")  # => "<p>Hello, world!</p>"

Use this when you want to forward messages to another object while maintaining encapsulation.

5. Role/trait composition (similar to mixins but more flexible)

Used for the additions of dynamic behavior. This is where objects can gain or lose behaviors at runtime:

module Swimming
  def swim = "swimming"
end

module Flying
  def fly = "flying"
end

class Bird
  def initialize = (@behaviors = [])
  
  def add_behavior(behavior)
    extend(behavior)
    @behaviors << behavior
  end
  
  # Note: In real code, you'd need a more sophisticated way to remove modules
  # as this would only remove the behavior from the array and not undo the extend
  # operation.
  def remove_behavior(behavior) = @behaviors.delete(behavior)
end

# Usage
duck = Bird.new
duck.add_behavior(Swimming)
duck.add_behavior(Flying)
duck.swim  # => "swimming"
duck.fly   # => "flying"

Use this when you want to add or remove behaviors dynamically.

6. Strategy pattern

This is similar to dependency injection but focused on encapsulating algorithms. This is one of the more commonly used patterns in real-world applications.

class PricingStrategy
  def calculate_price(base_price) = raise NotImplementedError
end

class RegularPricing < PricingStrategy
  def calculate_price(base_price) = base_price
end

class DiscountPricing < PricingStrategy
  def calculate_price(base_price) = (base_price * 0.9)
end

class Product
  def initialize(pricing_strategy) = (@pricing_strategy = pricing_strategy)
  def price(base_price) = (@pricing_strategy.calculate_price(base_price))
end

# Usage
product = Product.new(DiscountPricing.new)
product.price(10)  # => 9.0

Notice that the example above appears to use inheritance, but the base class PricingStrategy is an abstract class that defines an interface for concrete strategies to implement. This is a form of composition because the Product class is composed of a pricing strategy.

Use the strategy pattern when you want to encapsulate algorithms and make them interchangeable.

7. Decorator pattern

This is where objects can be wrapped in other objects to add behaviors.

class Coffee
  def cost = 3.00
  def description = "Plain coffee"
end

class MilkDecorator
  def initialize(coffee) = (@coffee = coffee)
  def cost = (@coffee.cost + 0.50)
  def description = (@coffee.description + " with milk")
end

# Usage
coffee = Coffee.new
coffee_with_milk = MilkDecorator.new(coffee)
coffee_with_milk.cost  # => 3.50

Use the decorator pattern when you want to add behaviors to objects at runtime.

Now that we’ve seen the various ways objects can work together through composition, let’s tackle the big question: Why should we usually prefer these approaches over inheritance?

Favoring composition over inheritance

The core argument for composition over inheritance is that inheritance can lead to tight coupling between parent and child classes. This can lead to several problems:

1. Fragile base class problem

When you change something in a parent class, it can unexpectedly break child classes in ways that are hard to predict. For example, if you have a Bird parent class and modify how its fly() method works, you might accidentally break specific bird implementations that were relying on the old behavior.

2. Rigid hierarchy

Inheritance hierarchies are fixed at compile-time and can’t be changed at runtime. With composition, you can easily swap behaviors at runtime, making your code more flexible.

3. The “is-a” relationship isn’t always clear

Sometimes what seems like an “is-a” relationship isn’t actually appropriate. The classic example is Square inheriting from Rectangle. While mathematically a square is a rectangle, in code this can violate the Liskov Substitution Principle because a square’s width and height must always be equal.

4. Limited to single inheritance

In languages with single inheritance (like Java, Ruby, etc.), you’re forced to choose one parent class. With composition, you can combine multiple behaviors.

5. Testing and maintenance

Composition makes testing easier because you can mock or stub out components. It also makes maintenance easier because changes are more localized and don’t ripple through an inheritance hierarchy.

Conclusion

We’ve covered a lot of ground in our exploration of composition! While inheritance is a fundamental feature of object-oriented programming, we’ve seen how composition often provides more flexible, maintainable solutions. Let’s recap when to use each approach.

Choose composition when:

  • You need runtime flexibility (like our Bird with swappable movement behaviors)
  • The relationship between objects is “has-a” rather than “is-a”
  • You want to keep your components loosely coupled
  • You need to combine multiple behaviors (remember our swimming, flying duck!)

Stick with inheritance when:

  • You have a true “is-a” relationship that won’t change over time
  • The parent class is stable and well-designed
  • You’re extending framework classes that expect inheritance
  • The child class is a proper behavioral subtype of the parent

Think of composition as building with LEGO blocks instead of sculpting from a single piece of clay. With LEGO, you can easily snap pieces together, rearrange them, or swap them out. That flexibility is exactly what makes composition so powerful in software design.

In our next article, we’ll explore another powerful OOD principle: the Law of Demeter. We’ll see how this principle helps us write code that’s more maintainable by reducing dependencies between objects. Until then, try looking at your current codebase - are there places where composition might work better than inheritance?

Remember: Good design isn’t about following rules blindly. It’s about understanding the trade-offs and choosing the right tool for the job. Sometimes that tool will be inheritance, but more often than not, composition will give you the flexibility you need to build maintainable, adaptable software.