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.
- Cover image designed by Freepik