Clean Code: Composition Over Inheritance
In object-oriented programming, we often think in terms of inheritance. A Dog
is an Animal
. A Car
is a Vehicle
. This is the "is-a" relationship, and it's a powerful way to model the world. But it has its limits.
What happens when you have a Penguin
? A penguin is a Bird
, but it can't fly. If your Bird
class has a fly()
method, you end up with a confusing situation. This is where composition comes in. Instead of saying a thing is a another thing, we can say it has a capability.
This leads to the principle: prefer composition over inheritance. It gives you more flexibility and avoids the problems of rigid class hierarchies.
The Problem with Deep Inheritance
Deep inheritance chains can be brittle. A change in a base class can have unintended consequences in its subclasses. Let's look at the classic bird example.
// Bad: A rigid inheritance hierarchy
class Animal {
move() { /* ... */ }
eat() { /* ... */ }
}
class Bird extends Animal {
fly() { /* ... */ }
}
class Penguin extends Bird {
// This is awkward. Penguins are birds, but they can't fly.
// This violates the Liskov Substitution Principle.
fly() {
throw new Error("Penguins can't fly!");
}
}
class Duck extends Bird {
swim() { /* ... */ }
}
// Good: Using composition to build objects from capabilities
interface Movable {
move(): void;
}
interface Flyable {
fly(): void;
}
interface Swimmable {
swim(): void;
}
class Bird {
private movable: Movable;
private flyable?: Flyable;
private swimmable?: Swimmable;
constructor(movable: Movable, flyable?: Flyable, swimmable?: Swimmable) {
this.movable = movable;
this.flyable = flyable;
this.swimmable = swimmable;
}
move() {
this.movable.move();
}
fly() {
if (this.flyable) {
this.flyable.fly();
}
}
swim() {
if (this.swimmable) {
this.swimmable.swim();
}
}
}
With composition, we can create a Duck
that can move, fly, and swim, and a Penguin
that can move and swim, without any confusing inheritance problems.
The Strategy Pattern: A Form of Composition
The Strategy Pattern is a perfect example of composition in action. Instead of using inheritance to define different behaviors, you use composition to inject them at runtime.
Let's say you're building a payment processor.
// Bad: Using inheritance for different payment methods
abstract class PaymentMethod {
abstract processPayment(amount: number): void;
}
class CreditCardPayment extends PaymentMethod {
processPayment(amount: number) {
// Credit card specific logic
}
}
class PayPalPayment extends PaymentMethod {
processPayment(amount: number) {
// PayPal specific logic
}
}
// Good: Using composition and the Strategy Pattern
interface PaymentStrategy {
processPayment(amount: number): void;
}
class CreditCardStrategy implements PaymentStrategy {
processPayment(amount: number) {
// Credit card specific logic
}
}
class PayPalStrategy implements PaymentStrategy {
processPayment(amount: number) {
// PayPal specific logic
}
}
class PaymentProcessor {
private strategy: PaymentStrategy;
constructor(strategy: PaymentStrategy) {
this.strategy = strategy;
}
setStrategy(strategy: PaymentStrategy) {
this.strategy = strategy;
}
processPayment(amount: number) {
this.strategy.processPayment(amount);
}
}
With this setup, you can easily add new payment methods without changing the PaymentProcessor
. You just create a new class that implements the PaymentStrategy
interface. This is the Open/Closed Principle in action, enabled by composition.
When to Choose?
- Inheritance (is-a): Use it when a class is a true subtype of another. Think
CheckingAccount
andSavingsAccount
as subtypes ofBankAccount
. They share a core identity. - Composition (has-a): Use it when you want to give an object a certain capability. Think of a
Car
that has anEngine
. It's more about roles and responsibilities than identity.
Favoring composition leads to more flexible, modular, and maintainable code. It's a powerful tool to have in your clean coding toolbox.