Skip to content
ioob.dev
Go back

OOP Design Principles Part 5 — Composition over Inheritance

· 6 min read
OOP & SOLID Series (5/5)
  1. OOP Design Principles Part 1 — SRP and OCP
  2. OOP Design Principles Part 2 — LSP and ISP
  3. OOP Design Principles Part 3 — DIP
  4. OOP Design Principles Part 4 — TDA, Law of Demeter, CQS
  5. OOP Design Principles Part 5 — Composition over Inheritance
Table of contents

Table of contents

When Inheritance Seems Natural

Suppose you’re building a notification system. Email notification is the default, and SMS notification needs to be added. The first design that comes to mind looks like this.

Common logic goes in the parent class, with each channel implemented as a subclass.

public abstract class Notification {

    protected String recipient;
    protected String message;

    public Notification(String recipient, String message) {
        this.recipient = recipient;
        this.message = message;
    }

    public void send() {
        validate();
        log();
        doSend();
    }

    protected void validate() {
        if (recipient == null || message == null) {
            throw new IllegalArgumentException("Recipient and message are required");
        }
    }

    protected void log() {
        System.out.println("Sending notification: " + recipient);
    }

    protected abstract void doSend();
}

public class EmailNotification extends Notification {

    public EmailNotification(String recipient, String message) {
        super(recipient, message);
    }

    @Override
    protected void doSend() {
        // Send email via SMTP
    }
}

public class SmsNotification extends Notification {

    public SmsNotification(String recipient, String message) {
        super(recipient, message);
    }

    @Override
    protected void doSend() {
        // Call SMS API
    }
}

Looks clean. Common logic (validate, log) in the parent, channel-specific logic in the children. No problems so far.

What Inheritance Breaks

The Fragile Base Class Problem

The Fragile Base Class Problem: a small change to the parent class breaks child classes in unexpected ways.

Suppose a retry feature is added to the notification system. The parent class’s send() method is modified.

Adding retry logic to the parent class.

public abstract class Notification {

    // ... existing fields, constructor

    public void send() {
        validate();
        log();
        doSend();
    }

    public void sendWithRetry(int maxRetries) {
        for (int i = 0; i <= maxRetries; i++) {
            try {
                send();
                return;
            } catch (Exception e) {
                if (i == maxRetries) throw e;
                // Wait before retrying
            }
        }
    }

    // ...
}

On the surface, this looks like a safe change. But what if SmsNotification had already overridden send()?

public class SmsNotification extends Notification {

    @Override
    public void send() {
        // SMS has different validation logic
        if (!recipient.matches("\\d{10,11}")) {
            throw new IllegalArgumentException("Invalid phone number");
        }
        doSend();
    }

    @Override
    protected void doSend() {
        // Call SMS API
    }
}

Since SmsNotification overrode send(), the send() called by sendWithRetry() is the child’s version. The parent’s log() never executes. The parent class developer assumed log() would always run, but the child class broke that assumption by redefining send().

This is the core of the fragile base class problem. The child must know the parent’s implementation, and when the parent changes, the child breaks. Inheritance violates encapsulation.

Combinatorial Explosion

As requirements grow, inheritance becomes even more dangerous. Suppose the notification system needs these features:

Solving this with inheritance causes a combinatorial explosion. EncryptedEmailNotification, ScheduledSmsNotification, EncryptedScheduledEmailNotification — with just 2 channels and 3 options, the number of combinations grows exponentially.

flowchart TB
    subgraph inheritance["Inheritance-based — Combinatorial explosion"]
        direction TB
        BASE["Notification"]
        EMAIL["EmailNotification"]
        SMS["SmsNotification"]
        EE["EncryptedEmail<br/>Notification"]
        SE["ScheduledEmail<br/>Notification"]
        ESE["EncryptedScheduled<br/>EmailNotification"]
        ES["EncryptedSms<br/>Notification"]
        SS["ScheduledSms<br/>Notification"]
        ESS["EncryptedScheduled<br/>SmsNotification"]
        BASE --> EMAIL
        BASE --> SMS
        EMAIL --> EE
        EMAIL --> SE
        EMAIL --> ESE
        SMS --> ES
        SMS --> SS
        SMS --> ESS
    end
    style ESE fill:#ff6b6b,color:#fff
    style ESS fill:#ff6b6b,color:#fff

8 classes. Add one more option and it becomes 16. No one can maintain this structure.

The Diamond Problem

Java doesn’t allow multiple inheritance. There’s a reason. If B and C both extend A, and D extends both B and C, it’s ambiguous which version of A’s method D should call — B’s or C’s. This is the Diamond Problem.

Java prevented this at the language level, but the trade-off is that it’s impossible to simultaneously inherit “email sending capability” and “logging capability.” Inheritance only extends along a single path, making it structurally disadvantaged for handling cross-cutting concerns.

Achieving the Same with Composition

Let’s see how composition solves the problems inheritance creates.

The core idea is simple. Instead of inheriting functionality from a “parent,” assemble it from “parts.”

First, separate the sending channel into an interface.

public interface MessageSender {
    void send(String recipient, String message);
}

public class EmailSender implements MessageSender {

    @Override
    public void send(String recipient, String message) {
        // SMTP sending
    }
}

public class SmsSender implements MessageSender {

    @Override
    public void send(String recipient, String message) {
        // SMS API call
    }
}

Additional features are also separated into interfaces. Apply the Decorator pattern.

Decorators that wrap MessageSender to add functionality.

public class EncryptingSender implements MessageSender {

    private final MessageSender delegate;

    public EncryptingSender(MessageSender delegate) {
        this.delegate = delegate;
    }

    @Override
    public void send(String recipient, String message) {
        String encrypted = encrypt(message);
        delegate.send(recipient, encrypted);
    }

    private String encrypt(String message) {
        // Encryption logic
        return "encrypted:" + message;
    }
}

public class LoggingSender implements MessageSender {

    private final MessageSender delegate;

    public LoggingSender(MessageSender delegate) {
        this.delegate = delegate;
    }

    @Override
    public void send(String recipient, String message) {
        System.out.println("Send started: " + recipient);
        delegate.send(recipient, message);
        System.out.println("Send completed: " + recipient);
    }
}

Assembly happens at the call site.

// Email + logging
MessageSender sender = new LoggingSender(new EmailSender());

// SMS + encryption + logging
MessageSender sender = new LoggingSender(new EncryptingSender(new SmsSender()));

// Email + encryption only
MessageSender sender = new EncryptingSender(new EmailSender());

The inheritance structure that required 8 classes is now expressed with 4 classes (2 channels + 2 decorators) covering all combinations. When a new feature is added, just create one more decorator. No need to modify existing code. This aligns well with OCP too.

flowchart LR
    subgraph composition["Composition-based — Assembling parts"]
        direction LR
        IF["MessageSender<br/>Interface"]
        EMAIL2["EmailSender"]
        SMS2["SmsSender"]
        ENC["EncryptingSender<br/>Decorator"]
        LOG["LoggingSender<br/>Decorator"]
        EMAIL2 -->|"implements"| IF
        SMS2 -->|"implements"| IF
        ENC -->|"implements"| IF
        LOG -->|"implements"| IF
        ENC -->|"delegates"| IF
        LOG -->|"delegates"| IF
    end
    style IF fill:#339af0,color:#fff

All classes depend on the MessageSender interface. Decorators implement the same interface while holding another MessageSender internally. This structure allows decorators to be stacked freely.

Kotlin’s by Delegation

Kotlin supports composition at the language level. Using the by keyword, you can delegate interface implementation to another object.

Creating decorators in Java requires overriding every method of the interface. For an interface with 10 methods, you’d need to fill all 10 with delegation code. Kotlin’s by eliminates this boilerplate.

A decorator using by.

interface MessageSender {
    fun send(recipient: String, message: String)
    fun validate(recipient: String): Boolean
    fun getStatus(): String
}

class LoggingSender(
    private val delegate: MessageSender
) : MessageSender by delegate {

    // Override only send, rest is automatically delegated
    override fun send(recipient: String, message: String) {
        println("Send started: $recipient")
        delegate.send(recipient, message)
        println("Send completed: $recipient")
    }
}

validate() and getStatus() are automatically delegated to delegate without explicit implementation. The code stays concise while retaining the flexibility of composition.

Using by, you can create code that looks like inheritance but actually works through composition.

An example of composing implementations from multiple interfaces.

interface Logger {
    fun log(message: String)
}

interface Validator {
    fun validate(input: String): Boolean
}

class ConsoleLogger : Logger {
    override fun log(message: String) = println("[LOG] $message")
}

class RegexValidator : Validator {
    override fun validate(input: String): Boolean = input.matches(Regex(".+@.+\\..+"))
}

// Composing implementations of two interfaces
class NotificationService(
    logger: Logger,
    validator: Validator
) : Logger by logger, Validator by validator {

    fun notify(email: String, message: String) {
        if (validate(email)) {
            log("Sending: $email")
            // Actual sending logic
        }
    }
}

NotificationService hasn’t inherited from Logger and Validator. It delegates to the implementations injected at creation time. You can swap ConsoleLogger for FileLogger or RegexValidator for StrictValidator at any time.

While multiple inheritance is impossible in Java, Kotlin’s by delegation lets you freely compose implementations of multiple interfaces in a single class. This is “favor composition over inheritance” realized at the language level.

When Inheritance Is Appropriate

“Favor composition over inheritance” doesn’t mean never use inheritance. There are clear cases where inheritance is the right choice.

When the is-a Relationship Is Genuine

The legitimacy of inheritance depends on the truth of the is-a relationship. Cat is an Animal — true. A cat is an animal. ArrayList is a List — true. EmailNotification is a Notification — this one deserves some thought.

The criterion for judging is-a relationships is the Liskov Substitution Principle (LSP), which we covered in Part 2. Can you replace the parent type with the child type everywhere the parent is used and have the program still work correctly? If so, the is-a relationship is genuine, and inheritance is appropriate.

When the Framework Requires Inheritance

Practically, there are cases where frameworks force inheritance. Android’s Activity and Spring’s AbstractRoutingDataSource are examples. In such cases, follow the framework’s design. Trying to force composition instead only makes the code more complex.

Interface Inheritance, Not Implementation Inheritance

Implementing a Java interface or Kotlin interface doesn’t conflict with composition. The problem is implementation inheritance (extending a concrete class). Interface inheritance defines a type contract — it’s not about inheriting implementation. Interfaces are a key tool even in composition-based design.

Decision Criteria Summary

The criteria for choosing between inheritance and composition can be summarized as follows.

// Inheritance is appropriate — genuine is-a, LSP satisfied
abstract class Shape {
    abstract fun area(): Double
}

class Circle(private val radius: Double) : Shape() {
    override fun area(): Double = Math.PI * radius * radius
}

class Rectangle(private val width: Double, private val height: Double) : Shape() {
    override fun area(): Double = width * height
}

// Composition is appropriate — has-a, assembling capabilities
class NotificationService(
    private val sender: MessageSender,   // has-a
    private val logger: Logger           // has-a
) {
    fun notify(recipient: String, message: String) {
        logger.log("Sending: $recipient")
        sender.send(recipient, message)
    }
}

A Circle is a Shape — the is-a relationship is clear. NotificationService has a MessageSender — it’s a has-a relationship, so composition is appropriate.

Before using inheritance, just ask yourself one question: “Can this class substitute for the parent class in all its behaviors?” If the answer is “no” or you’re unsure, consider composition first.

Series Wrap-Up

Across five parts, we’ve explored the major principles of object-oriented design.

PartPrinciplesCore Ideas
Part 1SRP, OCPSingle reason for change, extensible structure
Part 2LSP, ISPSubstitutable polymorphism, client-oriented interfaces
Part 3DIPDirect dependencies toward abstractions
Part 4TDA, Law of Demeter, CQSRules of communication between objects
Part 5Composition over InheritanceLimitations of inheritance, flexibility of composition

These principles don’t exist in isolation. SRP separates responsibilities, OCP creates extension points, LSP ensures polymorphism correctness, ISP segregates interfaces, and DIP establishes dependency direction. On top of that, TDA, the Law of Demeter, and CQS elevate the quality of messages between objects, and composition provides the foundation for flexible structures.

Knowing the principles and applying them are different matters. You need to accumulate experience encountering situations where principles conflict in real code, weighing trade-offs, and ultimately choosing the better option before they truly become your own.


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
OOP Design Principles Part 4 — TDA, Law of Demeter, CQS
Next Post
Design Patterns Part 1 — Why Learn Patterns