Composition Over Inheritance: Why and How
Understanding why composition is often preferred over inheritance, with practical examples showing how to refactor class hierarchies into flexible, composable designs.
“Favor composition over inheritance” is one of the most cited principles in software design. The Gang of Four mentioned it in 1994, and it remains just as relevant today. But what does it actually mean, and when should you still use inheritance?
The Inheritance Trap
Inheritance seems intuitive at first. You have a Bird class, and some birds can fly, so you create FlyingBird. But then you need a Penguin…
// ❌ The classic inheritance explosion
class Animal {
eat(): void { console.log('Eating'); }
}
class Bird extends Animal {
fly(): void { console.log('Flying'); }
layEggs(): void { console.log('Laying eggs'); }
}
class Penguin extends Bird {
fly(): void {
throw new Error("Penguins can't fly!"); // 😬 LSP violation
}
swim(): void { console.log('Swimming'); }
}
class FlyingFish extends Animal {
// Wait... fish can fly too? Our hierarchy doesn't support this
fly(): void { console.log('Gliding'); }
swim(): void { console.log('Swimming'); }
}
The problem: real-world behaviors don’t fit neatly into single-inheritance trees. You end up with either:
- Deep hierarchies that are rigid and hard to change
- Method overrides that violate Liskov Substitution (like
Penguin.fly()) - Code duplication because behaviors can’t be shared across branches
The Composition Approach
Instead of “is-a” relationships, model behaviors as components that can be mixed:
// ✅ Behaviors as composable interfaces and implementations
interface CanFly {
fly(): void;
}
interface CanSwim {
swim(): void;
}
interface CanWalk {
walk(): void;
}
// Behavior implementations
const flyer = (): CanFly => ({
fly() { console.log('Flying through the sky'); }
});
const swimmer = (): CanSwim => ({
swim() { console.log('Swimming through water'); }
});
const walker = (): CanWalk => ({
walk() { console.log('Walking on land'); }
});
// Compose animals from behaviors
function createDuck() {
return {
name: 'Duck',
...flyer(),
...swimmer(),
...walker(),
quack() { console.log('Quack!'); }
};
}
function createPenguin() {
return {
name: 'Penguin',
...swimmer(),
...walker(),
// No fly() — penguins just don't have it
};
}
function createFlyingFish() {
return {
name: 'Flying Fish',
...flyer(),
...swimmer(),
};
}
Now behaviors are independent, reusable, and can be combined freely.
Real-World Example: Notification System
Let’s see a more realistic scenario. You’re building a notification system that needs to support multiple channels and formatting options.
The Inheritance Approach (Problematic)
// ❌ Inheritance creates a combinatorial explosion
class Notification { /* base */ }
class EmailNotification extends Notification { /* email logic */ }
class SlackNotification extends Notification { /* slack logic */ }
class HTMLEmailNotification extends EmailNotification { /* html format */ }
class PlainTextEmailNotification extends EmailNotification { /* text format */ }
class UrgentHTMLEmailNotification extends HTMLEmailNotification { /* priority */ }
// ... it never ends
The Composition Approach (Flexible)
// ✅ Compose notification from independent concerns
interface MessageFormatter {
format(content: string, metadata: NotificationMeta): string;
}
interface DeliveryChannel {
send(recipient: string, formattedContent: string): Promise<void>;
}
interface PriorityHandler {
getPriority(notification: NotificationMeta): 'low' | 'normal' | 'high' | 'urgent';
}
// Implementations
class HTMLFormatter implements MessageFormatter {
format(content: string, meta: NotificationMeta): string {
return `<div class="notification">
<h2>${meta.title}</h2>
<p>${content}</p>
</div>`;
}
}
class PlainTextFormatter implements MessageFormatter {
format(content: string, meta: NotificationMeta): string {
return `${meta.title}\n${'='.repeat(meta.title.length)}\n\n${content}`;
}
}
class EmailChannel implements DeliveryChannel {
async send(recipient: string, content: string): Promise<void> {
await this.mailer.send({ to: recipient, body: content });
}
}
class SlackChannel implements DeliveryChannel {
async send(channel: string, content: string): Promise<void> {
await this.slack.postMessage({ channel, text: content });
}
}
// The notification service composes these pieces
class NotificationService {
constructor(
private formatter: MessageFormatter,
private channel: DeliveryChannel,
private priority: PriorityHandler,
) {}
async notify(recipient: string, content: string, meta: NotificationMeta) {
const formatted = this.formatter.format(content, meta);
const priority = this.priority.getPriority(meta);
if (priority === 'urgent') {
// Could add special handling
}
await this.channel.send(recipient, formatted);
}
}
// Mix and match freely
const urgentSlack = new NotificationService(
new PlainTextFormatter(),
new SlackChannel(),
new UrgencyBasedPriority(),
);
const prettyEmail = new NotificationService(
new HTMLFormatter(),
new EmailChannel(),
new DefaultPriority(),
);
When Inheritance IS Appropriate
Inheritance isn’t always wrong. Use it when:
- True “is-a” relationships:
HttpErrorextendsError— this is genuinely an error - Template Method pattern: A base class defines an algorithm skeleton, subclasses fill in steps
- Framework requirements: Many frameworks expect class inheritance (Angular components, React class components)
// ✅ Good use of inheritance — genuine specialization
class AppError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly code: string,
) {
super(message);
this.name = this.constructor.name;
}
}
class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
class ValidationError extends AppError {
constructor(message: string) {
super(message, 400, 'VALIDATION_ERROR');
}
}
The Decision Framework
Ask yourself:
- Does the child truly specialize the parent? → Inheritance might work
- Do I need to combine multiple behaviors? → Use composition
- Might the relationship change? → Prefer composition
- Is the hierarchy more than 2 levels deep? → Refactor to composition
- Am I inheriting just to reuse code? → Definitely use composition
“Inheritance is a powerful mechanism, but it should be used sparingly. When you do use it, it should model a clear ‘is-a’ relationship.” — Erich Gamma