Composition vs Inheritance — Favor Composition
Introduction
Imagine you're designing a notification system. You start with EmailNotification, then add SMSNotification, then someone asks for UrgentEmailNotification, then UrgentSMSNotification, then LoggedEmailNotification, then... your class hierarchy explodes.
You've hit the inheritance tax — the compounding cost of building features through class hierarchies. Every new combination demands a new subclass, and soon you have dozens of classes that are 90% identical.
The Gang of Four (GoF), who literally wrote the book on design patterns, stated this principle in 1994:
"Favor object composition over class inheritance." — Design Patterns: Elements of Reusable Object-Oriented Software
This is perhaps the most important design principle after SOLID. It doesn't mean "never use inheritance" — it means inheritance should be your second choice, not your default. Composition should be the tool you reach for first.
We've already learned what Composition is (S4 — dependent lifecycle, filled diamond ◆). Now we'll compare it against Inheritance as a code reuse strategy — building the SAME feature both ways so you can see the tradeoffs firsthand.
This tutorial connects directly to the Open/Closed Principle (S6) — because composition makes code open for extension without modification, while deep inheritance hierarchies make extension painful.
The Problem with Inheritance as a Reuse Strategy
Inheritance seems perfect for code reuse. Put shared behavior in a base class, specialize in subclasses. But there are three fundamental problems:
Problem 1: The Fragile Base Class Problem
When you change a base class, every subclass is affected — even if you only intended to fix one child. The base class is "fragile" because touching it sends shockwaves through the entire hierarchy.
Problem 2: The Combinatorial Explosion
If you have 3 independent behaviors (e.g., notification type, urgency, logging) and each has 2-3 options, inheritance demands 3 × 2 × 3 = 18 subclasses. With composition, you need 3 + 2 + 3 = 8 components that can be mixed freely.
Problem 3: Locked at Compile Time
Inheritance relationships are fixed when you write the class. A Dog is always an Animal. You can't change it at runtime. Composition lets you swap behaviors dynamically — changing a notification's delivery method at runtime based on user preferences.
The Litmus Test
Ask yourself: "Is this truly an is-a relationship, or am I just trying to reuse some code?"
Dogis-aAnimal→ Inheritance is appropriateCarhas-aEngine→ Composition is appropriateEncryptedLoggeris-aLogger... or does it have-aEncryptor? → Composition is almost always better here
Same Feature, Two Approaches — The Notification System
Let's implement the same notification system using both approaches. The requirements:
- Delivery channels: Email, SMS, Push
- Urgency levels: Normal, Urgent (adds retry logic)
- Optional features: Logging, Rate Limiting
With inheritance, every combination of channel × urgency × feature becomes a new class. With composition, each behavior is a pluggable component.
Watch how the inheritance version collapses under its own weight while the composition version stays clean.
Approach 1: Inheritance-Based Design
Here's the inheritance approach. It starts clean — then notice how quickly it spirals:
from abc import ABC, abstractmethod
# ---------- Layer 1: Base class ----------
class Notification(ABC):
@abstractmethod
def send(self, recipient: str, message: str) -> None:
pass
# ---------- Layer 2: Channel subclasses ----------
class EmailNotification(Notification):
def send(self, recipient: str, message: str) -> None:
print(f" [EMAIL] To: {recipient} | {message}")
class SMSNotification(Notification):
def send(self, recipient: str, message: str) -> None:
print(f" [SMS] To: {recipient} | {message}")
class PushNotification(Notification):
def send(self, recipient: str, message: str) -> None:
print(f" [PUSH] To: {recipient} | {message}")
# ---------- Layer 3: Urgency × Channel (3 × 2 = 6 classes!) ----------
class UrgentEmailNotification(EmailNotification):
def send(self, recipient: str, message: str) -> None:
print(" [URGENT] Retrying up to 3 times...")
for attempt in range(1, 4):
print(f" Attempt {attempt}:")
super().send(recipient, f"[URGENT] {message}")
class UrgentSMSNotification(SMSNotification):
def send(self, recipient: str, message: str) -> None:
print(" [URGENT] Retrying up to 3 times...")
for attempt in range(1, 4):
print(f" Attempt {attempt}:")
super().send(recipient, f"[URGENT] {message}")
# Layer 4: Logging × Urgency × Channel = exponential explosion!
# UrgentLoggedEmailNotification? LoggedUrgentSMSNotification?
# What about RateLimitedUrgentLoggedPushNotification??
# This is unsustainable. STOP HERE.
print("--- Inheritance approach ---")
email = EmailNotification()
email.send("alice@example.com", "Welcome!")
urgent_email = UrgentEmailNotification()
urgent_email.send("alice@example.com", "Server is down!")
# Problem: To add logging, you'd need LoggedEmailNotification,
# LoggedSMSNotification, LoggedPushNotification,
# LoggedUrgentEmailNotification, LoggedUrgentSMSNotification...
# For 3 channels × 2 urgency × 2 logging × 2 rate-limit = 24 CLASSES// Layer 1: Base class
abstract class Notification {
abstract void send(String recipient, String message);
}
// Layer 2: Channel subclasses
class EmailNotification extends Notification {
@Override
void send(String recipient, String message) {
System.out.println(" [EMAIL] To: " + recipient + " | " + message);
}
}
class SMSNotification extends Notification {
@Override
void send(String recipient, String message) {
System.out.println(" [SMS] To: " + recipient + " | " + message);
}
}
// Layer 3: Urgency × Channel (already 6 classes!)
class UrgentEmailNotification extends EmailNotification {
@Override
void send(String recipient, String message) {
System.out.println(" [URGENT] Retrying up to 3 times...");
for (int attempt = 1; attempt <= 3; attempt++) {
System.out.println(" Attempt " + attempt + ":");
super.send(recipient, "[URGENT] " + message);
}
}
}
// Layer 4: Logging × Urgency × Channel = EXPLOSION
// LoggedUrgentEmailNotification extends UrgentEmailNotification?
// What if we need LoggedNormalEmailNotification too?
// This path leads to class hierarchy hell.
public class Main {
public static void main(String[] args) {
System.out.println("--- Inheritance approach ---");
Notification email = new EmailNotification();
email.send("alice@example.com", "Welcome!");
Notification urgentEmail = new UrgentEmailNotification();
urgentEmail.send("alice@example.com", "Server is down!");
}
}Approach 2: Composition-Based Design
Now the same system using composition. Each behavior is a pluggable component that can be mixed and matched freely:
from abc import ABC, abstractmethod
from typing import List
# ---------- Step 1: Define the BEHAVIOR interfaces ----------
class DeliveryChannel(ABC):
"""HOW to deliver the notification (Email, SMS, Push)."""
@abstractmethod
def deliver(self, recipient: str, message: str) -> None:
pass
class UrgencyPolicy(ABC):
"""HOW urgently to handle the notification."""
@abstractmethod
def apply(self, channel: DeliveryChannel, recipient: str, message: str) -> None:
pass
# ---------- Step 2: Implement each behavior independently ----------
class EmailChannel(DeliveryChannel):
def deliver(self, recipient: str, message: str) -> None:
print(f" [EMAIL] To: {recipient} | {message}")
class SMSChannel(DeliveryChannel):
def deliver(self, recipient: str, message: str) -> None:
print(f" [SMS] To: {recipient} | {message}")
class PushChannel(DeliveryChannel):
def deliver(self, recipient: str, message: str) -> None:
print(f" [PUSH] To: {recipient} | {message}")
class NormalUrgency(UrgencyPolicy):
def apply(self, channel: DeliveryChannel, recipient: str, message: str) -> None:
channel.deliver(recipient, message)
class UrgentRetry(UrgencyPolicy):
def __init__(self, max_retries: int = 3):
self._max_retries = max_retries
def apply(self, channel: DeliveryChannel, recipient: str, message: str) -> None:
print(f" [URGENT] Retrying up to {self._max_retries} times...")
for attempt in range(1, self._max_retries + 1):
print(f" Attempt {attempt}:")
channel.deliver(recipient, f"[URGENT] {message}")
# ---------- Step 3: Compose behaviors in a single class ----------
class NotificationService:
"""
Composes channel + urgency + optional middleware.
No inheritance hierarchy — just pluggable components.
"""
def __init__(self, channel: DeliveryChannel, urgency: UrgencyPolicy,
log_enabled: bool = False, rate_limit: int = 0):
self._channel = channel # HAS-A delivery channel
self._urgency = urgency # HAS-A urgency policy
self._log_enabled = log_enabled
self._rate_limit = rate_limit
self._send_count = 0
def send(self, recipient: str, message: str) -> None:
if self._rate_limit > 0 and self._send_count >= self._rate_limit:
print(f" [RATE LIMITED] Blocked: {recipient}")
return
if self._log_enabled:
print(f" [LOG] Sending to {recipient}: {message}")
self._urgency.apply(self._channel, recipient, message)
self._send_count += 1
# ---------- Step 4: Mix and match ANY combination ----------
print("--- Composition approach ---")
# Normal email (same as before)
normal_email = NotificationService(EmailChannel(), NormalUrgency())
normal_email.send("alice@example.com", "Welcome!")
print()
# Urgent email with logging (no new class needed!)
urgent_logged_email = NotificationService(
channel=EmailChannel(),
urgency=UrgentRetry(max_retries=3),
log_enabled=True
)
urgent_logged_email.send("alice@example.com", "Server is down!")
print()
# Rate-limited urgent SMS with logging (still no new class!)
rate_limited_sms = NotificationService(
channel=SMSChannel(),
urgency=UrgentRetry(max_retries=2),
log_enabled=True,
rate_limit=5
)
rate_limited_sms.send("+1234567890", "Alert!")
print()
print("--- Adding a new channel requires ONE class, not 8 ---")
class SlackChannel(DeliveryChannel):
def deliver(self, recipient: str, message: str) -> None:
print(f" [SLACK] To #{recipient} | {message}")
# Instantly works with ALL existing urgency policies and features
urgent_slack = NotificationService(
channel=SlackChannel(),
urgency=UrgentRetry(),
log_enabled=True
)
urgent_slack.send("engineering", "Deploy failed!")// Step 1: Define behavior interfaces
interface DeliveryChannel {
void deliver(String recipient, String message);
}
interface UrgencyPolicy {
void apply(DeliveryChannel channel, String recipient, String message);
}
// Step 2: Implement each behavior independently
class EmailChannel implements DeliveryChannel {
@Override
public void deliver(String recipient, String message) {
System.out.println(" [EMAIL] To: " + recipient + " | " + message);
}
}
class SMSChannel implements DeliveryChannel {
@Override
public void deliver(String recipient, String message) {
System.out.println(" [SMS] To: " + recipient + " | " + message);
}
}
class NormalUrgency implements UrgencyPolicy {
@Override
public void apply(DeliveryChannel channel, String recipient, String message) {
channel.deliver(recipient, message);
}
}
class UrgentRetry implements UrgencyPolicy {
private final int maxRetries;
public UrgentRetry(int maxRetries) {
this.maxRetries = maxRetries;
}
@Override
public void apply(DeliveryChannel channel, String recipient, String message) {
System.out.println(" [URGENT] Retrying up to " + maxRetries + " times...");
for (int attempt = 1; attempt <= maxRetries; attempt++) {
System.out.println(" Attempt " + attempt + ":");
channel.deliver(recipient, "[URGENT] " + message);
}
}
}
// Step 3: Compose behaviors
class NotificationService {
private final DeliveryChannel channel;
private final UrgencyPolicy urgency;
private final boolean logEnabled;
private final int rateLimit;
private int sendCount = 0;
public NotificationService(DeliveryChannel channel, UrgencyPolicy urgency,
boolean logEnabled, int rateLimit) {
this.channel = channel;
this.urgency = urgency;
this.logEnabled = logEnabled;
this.rateLimit = rateLimit;
}
public void send(String recipient, String message) {
if (rateLimit > 0 && sendCount >= rateLimit) {
System.out.println(" [RATE LIMITED] Blocked: " + recipient);
return;
}
if (logEnabled) {
System.out.println(" [LOG] Sending to " + recipient + ": " + message);
}
urgency.apply(channel, recipient, message);
sendCount++;
}
}
public class Main {
public static void main(String[] args) {
System.out.println("--- Composition approach ---");
// Normal email
NotificationService normalEmail = new NotificationService(
new EmailChannel(), new NormalUrgency(), false, 0);
normalEmail.send("alice@example.com", "Welcome!");
System.out.println();
// Urgent email with logging — no new class!
NotificationService urgentLoggedEmail = new NotificationService(
new EmailChannel(), new UrgentRetry(3), true, 0);
urgentLoggedEmail.send("alice@example.com", "Server is down!");
System.out.println();
// Adding Slack requires ONE class, works with everything
DeliveryChannel slackChannel = (recipient, message) ->
System.out.println(" [SLACK] To #" + recipient + " | " + message);
NotificationService urgentSlack = new NotificationService(
slackChannel, new UrgentRetry(2), true, 0);
urgentSlack.send("engineering", "Deploy failed!");
}
}The Numbers Don't Lie — Comparison
Let's compare the two approaches with concrete numbers:
| Metric | Inheritance | Composition |
|---|---|---|
| Classes for 3 channels × 2 urgency levels | 9 classes (3 base + 6 combinations) | 7 classes (3 channels + 2 policies + 1 service + interfaces) |
| Adding a 4th channel (Slack) | +3 classes (Slack, UrgentSlack, LoggedSlack...) | +1 class (SlackChannel) |
| Adding logging feature | +6 classes (one per existing combination) | +0 classes (add boolean flag or middleware) |
| Adding rate limiting | +12 classes (doubles all logged variants) | +0 classes (add another parameter) |
| Total for full feature set | 24+ classes | 8 classes |
| Runtime behavior change | Impossible (class is fixed) | Swap channel or policy at runtime |
| Testing | Test each subclass individually | Test each component in isolation |
The inheritance version grows multiplicatively. Every new dimension (channel × urgency × logging × rate-limiting) multiplies the number of classes. The composition version grows additively — each new behavior is one new component that works with all existing components.

Visualization

When Inheritance IS the Right Choice
"Favor composition" doesn't mean "never inherit." Inheritance is appropriate when:
1. True Is-A Relationship
A SavingsAccount truly is-a BankAccount. It shares identity, behavior, and semantics. Substitutability holds — anywhere you use a BankAccount, a SavingsAccount works correctly (Liskov Substitution Principle, S6).
2. Shallow, Stable Hierarchies
One level of inheritance is usually fine. Shape → Circle, Rectangle, Triangle is clean because the hierarchy is shallow and unlikely to change. Problems emerge at depth 3+ when the base class becomes fragile.
3. Framework Extension Points
Frameworks intentionally design for inheritance: TestCase in unittest, Activity in Android, HttpServlet in Java EE. The framework controls the base class and promises stability.
4. Template Method Pattern
When you want to define an algorithm skeleton in a base class while letting subclasses override specific steps (S12 — Template Method Pattern), inheritance is the right tool.
The Decision Framework
Ask these questions in order:
Question 1: Is this a true is-a relationship?
- Can every instance of the child substitute for the parent WITHOUT violating expectations? (LSP)
- If NO → use composition
Question 2: How deep is the hierarchy?
- 1 level → inheritance is usually fine
- 2 levels → consider whether composition would be cleaner
- 3+ levels → almost certainly use composition
Question 3: Are there multiple independent dimensions of variation?
- If you see Channel × Urgency × Feature → composition (avoid combinatorial explosion)
- If there's only one dimension → inheritance may be fine
Question 4: Do you need runtime flexibility?
- Need to swap behaviors at runtime → composition
- Behaviors are fixed at compile time → inheritance is acceptable
When in doubt, choose composition. You can always refactor from composition to inheritance if a true is-a relationship emerges. Refactoring the other direction (inheritance → composition) is much harder because client code depends on the class hierarchy.
Common Mistakes
Mistake 1: Inheriting for Code Reuse Alone
The problem: Stack extends ArrayList because Stack needs list storage. Now Stack inherits add(), remove(index), get(index) — methods that violate stack semantics. Users can insert at arbitrary positions, breaking LIFO.
The fix: Stack should HAS-A List as a private field, exposing only push(), pop(), and peek(). This is exactly how Java's documentation recommends modeling stacks.
Mistake 2: Deep Inheritance Hierarchies
The problem: Vehicle → MotorVehicle → FourWheelVehicle → Car → ElectricCar → TeslaModelS. Six levels deep. Changing Vehicle could break everything. Understanding TeslaModelS requires reading 5 parent classes.
The fix: Flatten to Vehicle with composed behaviors: Engine (Electric, Gas, Hybrid), DriveType (FourWheel, TwoWheel), Brand. Three pluggable components instead of a rigid 6-level chain.
Mistake 3: Over-Composing Simple Relationships
The problem: Creating DogBehavior, DogSound, DogMovement interfaces and composing them into Dog, when Dog extends Animal is genuinely the right model. Not everything needs composition.
The fix: If it's a true type hierarchy with shallow depth and no combinatorial behaviors, inheritance is fine. Don't over-engineer.
Key Takeaways
- "Favor composition over inheritance" means composition should be your default — reach for inheritance only when it's clearly the right tool
- Inheritance creates multiplicative complexity — each new dimension of behavior multiplies the number of classes
- Composition creates additive complexity — each new behavior is one independent component
- Inheritance locks relationships at compile time; composition allows runtime flexibility
- Use inheritance for true is-a relationships with shallow hierarchies (1-2 levels)
- Use composition for code reuse, combinatorial behaviors, and runtime swapping
- The Fragile Base Class Problem makes deep hierarchies risky — a base class change can break dozens of subclasses
- This principle connects to Strategy Pattern (S12), Decorator Pattern (S11), Open/Closed Principle (S6), and Dependency Injection (S13) — all of which use composition to achieve flexibility
- When in doubt, compose. Refactoring from composition to inheritance is easier than the reverse