Table of contents
- When Inheritance Seems Natural
- What Inheritance Breaks
- Achieving the Same with Composition
- Kotlin’s by Delegation
- When Inheritance Is Appropriate
- Series Wrap-Up
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:
- Optional logging
- Encrypted sending
- Scheduled sending
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.
| Part | Principles | Core Ideas |
|---|---|---|
| Part 1 | SRP, OCP | Single reason for change, extensible structure |
| Part 2 | LSP, ISP | Substitutable polymorphism, client-oriented interfaces |
| Part 3 | DIP | Direct dependencies toward abstractions |
| Part 4 | TDA, Law of Demeter, CQS | Rules of communication between objects |
| Part 5 | Composition over Inheritance | Limitations 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.




Loading comments...