Inheritance vs Composition
TL;DR: Inheritance models "is-a" relationships and lets subclasses share a parent's implementation. Composition models "has-a" relationships by containing objects instead of extending them. Modern practice strongly favors composition because it's more flexible and less fragile. Use inheritance only when subclasses genuinely share behavior that belongs in a parent.

Why This Matters in Interviews
LLD interviews test whether you can design classes that are easy to extend without breaking. Reaching for inheritance by default is one of the most common mistakes candidates make. Interviewers will push back on deep inheritance hierarchies because they know those designs become rigid and fragile in real systems. Knowing when to use each approach, and being able to articulate why, separates strong candidates from average ones.
Inheritance: The "Is-A" Relationship
Inheritance lets a child class reuse the implementation of a parent class. A SavingsAccount IS A BankAccount, so it makes sense for it to inherit shared behavior like deposit() and withdraw().
class BankAccount {
private long balanceCents;
public BankAccount(long initialBalanceCents) {
this.balanceCents = initialBalanceCents;
}
public void deposit(long amountCents) {
if (amountCents <= 0) throw new IllegalArgumentException("Must be positive");
this.balanceCents += amountCents;
}
public void withdraw(long amountCents) {
if (amountCents <= 0) throw new IllegalArgumentException("Must be positive");
if (amountCents > balanceCents) throw new IllegalArgumentException("Insufficient funds");
this.balanceCents -= amountCents;
}
public long getBalanceCents() { return balanceCents; }
}
class SavingsAccount extends BankAccount {
private double interestRate;
public SavingsAccount(long initialBalanceCents, double interestRate) {
super(initialBalanceCents);
this.interestRate = interestRate;
}
public void applyInterest() {
long interest = (long)(getBalanceCents() * interestRate);
deposit(interest);
}
}
class CheckingAccount extends BankAccount {
public CheckingAccount(long initialBalanceCents) {
super(initialBalanceCents);
}
// Inherits deposit/withdraw as-is
}
class BankAccount {
private:
long balanceCents;
public:
BankAccount(long initialBalanceCents) : balanceCents(initialBalanceCents) {}
void deposit(long amountCents) {
if (amountCents <= 0) throw std::invalid_argument("Must be positive");
balanceCents += amountCents;
}
void withdraw(long amountCents) {
if (amountCents <= 0) throw std::invalid_argument("Must be positive");
if (amountCents > balanceCents) throw std::invalid_argument("Insufficient funds");
balanceCents -= amountCents;
}
long getBalanceCents() const { return balanceCents; }
};
class SavingsAccount : public BankAccount {
double interestRate;
public:
SavingsAccount(long initialBalanceCents, double interestRate)
: BankAccount(initialBalanceCents), interestRate(interestRate) {}
void applyInterest() {
long interest = (long)(getBalanceCents() * interestRate);
deposit(interest);
}
};
class BankAccount:
def __init__(self, initial_balance_cents: int):
self._balance_cents = initial_balance_cents
def deposit(self, amount_cents: int) -> None:
if amount_cents <= 0:
raise ValueError("Must be positive")
self._balance_cents += amount_cents
def withdraw(self, amount_cents: int) -> None:
if amount_cents <= 0:
raise ValueError("Must be positive")
if amount_cents > self._balance_cents:
raise ValueError("Insufficient funds")
self._balance_cents -= amount_cents
def get_balance_cents(self) -> int:
return self._balance_cents
class SavingsAccount(BankAccount):
def __init__(self, initial_balance_cents: int, interest_rate: float):
super().__init__(initial_balance_cents)
self._interest_rate = interest_rate
def apply_interest(self) -> None:
interest = int(self.get_balance_cents() * self._interest_rate)
self.deposit(interest)
This works well because SavingsAccount and CheckingAccount genuinely are bank accounts. The shared deposit() and withdraw() logic belongs in the parent.
Composition: The "Has-A" Relationship
Composition means a class contains an instance of another class instead of extending it. A Car HAS AN Engine -- it doesn't inherit from Engine.
class Engine {
private int horsepower;
public Engine(int horsepower) { this.horsepower = horsepower; }
public void start() { System.out.println("Engine started"); }
public int getHorsepower() { return horsepower; }
}
class Car {
private Engine engine; // Car HAS an Engine
public Car(Engine engine) { this.engine = engine; }
public void start() {
engine.start();
System.out.println("Car is ready to drive");
}
}
class Engine {
int horsepower;
public:
Engine(int horsepower) : horsepower(horsepower) {}
void start() { std::cout << "Engine started\n"; }
int getHorsepower() const { return horsepower; }
};
class Car {
Engine engine; // Car HAS an Engine
public:
Car(Engine engine) : engine(std::move(engine)) {}
void start() {
engine.start();
std::cout << "Car is ready to drive\n";
}
};
class Engine:
def __init__(self, horsepower: int):
self._horsepower = horsepower
def start(self) -> None:
print("Engine started")
class Car:
def __init__(self, engine: Engine):
self._engine = engine # Car HAS an Engine
def start(self) -> None:
self._engine.start()
print("Car is ready to drive")
With composition, you can swap in a different Engine without changing Car. With inheritance, Car would be permanently locked to whatever parent it extends.
The Fragile Base Class Problem
Inheritance creates tight coupling. When you change a parent class, you risk breaking every child that extends it.
// Original parent class
class OrderProcessor {
public void process(Order order) {
validate(order);
save(order);
}
public void validate(Order order) {
if (order.getItems().isEmpty()) throw new IllegalArgumentException("Empty order");
}
public void save(Order order) { /* persist to database */ }
}
// Child overrides validate to add a rule
class PremiumOrderProcessor extends OrderProcessor {
@Override
public void validate(Order order) {
super.validate(order); // Call parent validation first
if (order.getTotal() < 1000) throw new IllegalArgumentException("Premium min is $10");
}
}
Months later, someone refactors OrderProcessor.process() to stop calling validate() internally, or renames validate() to checkOrder(). PremiumOrderProcessor silently breaks -- its override no longer runs. The child depended on the parent's internal structure, and the parent changed without knowing it had dependents.
Interview tip: If an interviewer asks "what's wrong with deep inheritance hierarchies?", the fragile base class problem is the answer they're looking for. Changes at the top ripple unpredictably to all descendants.
The Diamond Problem
When a class inherits from two parents that share a common ancestor, which version of the shared method does it get? This is the diamond problem.
C++ allows multiple inheritance, which makes this a real issue:
class Animal {
public:
virtual void eat() { std::cout << "Animal eating\n"; }
};
class Flyer : public Animal {
public:
void eat() override { std::cout << "Flyer eating\n"; }
};
class Swimmer : public Animal {
public:
void eat() override { std::cout << "Swimmer eating\n"; }
};
// Which eat() does FlyingFish get? Ambiguous!
class FlyingFish : public Flyer, public Swimmer {};
// C++ fix: use virtual inheritance or explicitly disambiguate
Python resolves it with Method Resolution Order (MRO), which follows a deterministic left-to-right, depth-first linearization. Java avoids the problem entirely by not allowing multiple class inheritance -- you can only implement multiple interfaces, not extend multiple classes.
Interview tip: If you're coding in Java, mention that Java uses interfaces instead of multiple inheritance to avoid the diamond problem. It shows you understand the language design decision, not just the syntax.
Prefer Composition Over Inheritance
Here's the most common mistake candidates make: using inheritance to reuse code even when there's no "is-a" relationship.
// BAD: NotificationService inherits from EmailSender
// A notification service IS NOT an email sender
class EmailSender {
public void sendEmail(String to, String body) { /* SMTP logic */ }
}
class NotificationService extends EmailSender {
public void notifyUser(String userId, String message) {
String email = lookupEmail(userId);
sendEmail(email, message); // "Convenient" reuse via inheritance
}
}
// Problem: NotificationService is permanently locked to email.
// What if you need SMS? Push notifications? You can't swap them in.
// GOOD: NotificationService composes with a MessageSender interface
interface MessageSender {
void send(String destination, String body);
}
class EmailSender implements MessageSender {
public void send(String destination, String body) { /* SMTP logic */ }
}
class SmsSender implements MessageSender {
public void send(String destination, String body) { /* SMS API logic */ }
}
class NotificationService {
private final MessageSender sender; // Composed, not inherited
public NotificationService(MessageSender sender) {
this.sender = sender;
}
public void notifyUser(String userId, String message) {
String destination = lookupDestination(userId);
sender.send(destination, message);
}
}
// Now you can swap implementations without changing NotificationService
new NotificationService(new EmailSender());
new NotificationService(new SmsSender());
// GOOD: Composition with an abstract base class (C++ "interface")
class MessageSender {
public:
virtual void send(const std::string& destination, const std::string& body) = 0;
virtual ~MessageSender() = default;
};
class EmailSender : public MessageSender {
public:
void send(const std::string& destination, const std::string& body) override {
// SMTP logic
}
};
class NotificationService {
std::unique_ptr<MessageSender> sender;
public:
NotificationService(std::unique_ptr<MessageSender> sender)
: sender(std::move(sender)) {}
void notifyUser(const std::string& userId, const std::string& message) {
std::string dest = lookupDestination(userId);
sender->send(dest, message);
}
};
# GOOD: Composition with a protocol/ABC
from abc import ABC, abstractmethod
class MessageSender(ABC):
@abstractmethod
def send(self, destination: str, body: str) -> None: ...
class EmailSender(MessageSender):
def send(self, destination: str, body: str) -> None:
pass # SMTP logic
class SmsSender(MessageSender):
def send(self, destination: str, body: str) -> None:
pass # SMS API logic
class NotificationService:
def __init__(self, sender: MessageSender):
self._sender = sender # Composed, not inherited
def notify_user(self, user_id: str, message: str) -> None:
destination = self._lookup_destination(user_id)
self._sender.send(destination, message)
The composition version is more flexible, easier to test (you can inject a mock MessageSender), and follows the Single Responsibility Principle.
The Practical Rule
Use inheritance when:
- The relationship is genuinely "is-a" (
SavingsAccountIS ABankAccount) - Subclasses share implementation that naturally belongs in the parent
- The hierarchy is shallow (one or two levels deep)
Use composition for everything else. Especially when:
- You want to swap behavior at runtime
- The "reuse" is just code convenience, not a real type relationship
- You find yourself building hierarchies three or more levels deep
Quick Recap
| Concept | What it means | When to use |
|---|---|---|
| Inheritance | Child extends parent ("is-a") | Shared behavior that genuinely belongs in a parent class |
| Composition | Class contains another class ("has-a") | Flexible reuse, swappable behavior, most cases |
| Fragile base class | Changing a parent can silently break children | Reason to keep hierarchies shallow |
| Diamond problem | Ambiguity from multiple inheritance | Java avoids with interfaces; C++ uses virtual inheritance |
| Prefer composition | Default to "has-a" over "is-a" | Unless inheritance clearly fits the domain |