Skip to main content

Composition vs Inheritance — Favor Composition

Editorial

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?"

  • Dog is-a Animal → Inheritance is appropriate
  • Car has-a Engine → Composition is appropriate
  • EncryptedLogger is-a Logger... or does it have-a Encryptor? → 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:

  1. Delivery channels: Email, SMS, Push
  2. Urgency levels: Normal, Urgent (adds retry logic)
  3. 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:

MetricInheritanceComposition
Classes for 3 channels × 2 urgency levels9 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 set24+ classes8 classes
Runtime behavior changeImpossible (class is fixed)Swap channel or policy at runtime
TestingTest each subclass individuallyTest 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.

Favor composition over inheritance (Gang of Four). Inheritance multiplies: every combination of channel x urgency x feature is a new subclass - 3 channels x 2 urgency x 2 logging x 2 rate-limit = 24 classes, and adding one channel adds 8 more. Composition adds: each behavior is a pluggable component (Email, SMS, Push, Normal, Urgent, Logging, RateLimit) mixed at runtime - 3 + 2 + 1 + 1 = 7 components, adding a channel adds 1. Three inheritance problems: fragile base class, combinatorial explosion, locked at compile time. Litmus: is-a -> inheritance (Dog is-a Animal); has-a or just reusing code -> composition (Car has-a Engine). Composition stays open for extension (OCP).

Visualization

Adjust the feature dimensions (channels, urgency, logging, rate-limit) and watch the inheritance class count explode as a PRODUCT while the composition component count grows as a SUM. Add one channel: inheritance jumps by many classes, composition by exactly one. That's the inheritance tax.

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