Inheritance — and When It's Wrong
Inheritance lets a subclass reuse the implementation of a base class and extend or override specific parts. It works well when there is genuine shared implementation that every subclass uses without contradiction. It breaks down when the goal is to model a difference in behavior with a difference in type — the two are not the same thing, and conflating them produces fragile hierarchies.
When inheritance is the right tool
Inheritance works cleanly when there is genuine shared implementation that every subclass will
use without overriding. Consider bank accounts: a SavingsAccount and a CheckingAccount both
maintain a balance, both support deposits, and both need to validate that the balance never
drops below a minimum. That shared logic belongs in a BankAccount base class, and the
subclasses extend only what actually differs — the interest calculation for savings, the
overdraft policy for checking.
1from decimal import Decimal
2
3class BankAccount:
4 def __init__(self, owner: str, initial_balance: Decimal) -> None:
5 self._owner = owner
6 self._balance = initial_balance
7
8 def deposit(self, amount: Decimal) -> None:
9 if amount <= 0:
10 raise ValueError("deposit must be positive")
11 self._balance += amount
12
13 def withdraw(self, amount: Decimal) -> None:
14 if amount <= 0:
15 raise ValueError("amount must be positive")
16 if amount > self._balance:
17 raise ValueError("insufficient funds")
18 self._balance -= amount
19
20 @property
21 def balance(self) -> Decimal:
22 return self._balance
23
24class SavingsAccount(BankAccount):
25 def __init__(self, owner: str, initial: Decimal, interest_rate: float) -> None:
26 super().__init__(owner, initial)
27 self._interest_rate = interest_rate
28
29 def apply_monthly_interest(self) -> None:
30 interest = self._balance * Decimal(str(self._interest_rate / 12))
31 self._balance += interest
32
33class CheckingAccount(BankAccount):
34 def __init__(self, owner: str, initial: Decimal, overdraft_limit: Decimal) -> None:
35 super().__init__(owner, initial)
36 self._overdraft_limit = overdraft_limit
37
38 def withdraw(self, amount: Decimal) -> None:
39 if amount <= 0:
40 raise ValueError("amount must be positive")
41 if amount > self._balance + self._overdraft_limit:
42 raise ValueError("exceeds overdraft limit")
43 self._balance -= amount
When inheritance goes wrong: the fragile base class
Inheritance becomes problematic the moment the subclass needs to contradict something the base class does. The classic trap is forcing a type hierarchy to model behavioral differences that should instead be handled by composition.
Consider a vehicle hierarchy where Car has a start_engine() method. An ElectricCar has no
combustion engine, so it is forced to override start_engine() and pretend it starts an engine
— or throw an error, which breaks callers who assume all Car objects can start their engine.
This is the fragile base class problem: a change to the base class can silently break
subclasses, and a subclass that violates the base class's contract breaks callers that relied
on it.
1# Bad: ElectricCar is forced to override a method that does not apply to it.
2# This is an inheritance-for-behavior-variation smell.
3class Car:
4 def start_engine(self) -> None:
5 print("vroom")
6
7 def accelerate(self, speed_kmh: int) -> None:
8 print(f"accelerating to {speed_kmh} km/h")
9
10 def refuel(self, liters: float) -> None:
11 self._fuel += liters
12
13class ElectricCar(Car):
14 def start_engine(self) -> None:
15 # Has no combustion engine; this override is a lie.
16 print("silently activating")
17
18 def refuel(self, liters: float) -> None:
19 raise NotImplementedError("electric cars don't refuel") # breaks LSP
1from abc import ABC, abstractmethod
2
3# Good: use composition — vehicles share a common interface but delegate
4# propulsion to a PowerTrain object injected at construction.
5class PowerTrain(ABC):
6 @abstractmethod
7 def activate(self) -> None: ...
8
9 @abstractmethod
10 def energy_level(self) -> float: ...
11
12class CombustionEngine(PowerTrain):
13 def __init__(self, fuel_liters: float) -> None:
14 self._fuel = fuel_liters
15
16 def activate(self) -> None:
17 print("vroom")
18
19 def energy_level(self) -> float:
20 return self._fuel
21
22 def refuel(self, liters: float) -> None:
23 self._fuel += liters
24
25class ElectricMotor(PowerTrain):
26 def __init__(self, charge_kwh: float) -> None:
27 self._charge = charge_kwh
28
29 def activate(self) -> None:
30 print("silently activating")
31
32 def energy_level(self) -> float:
33 return self._charge
34
35 def recharge(self, kwh: float) -> None:
36 self._charge += kwh
37
38class Vehicle:
39 def __init__(self, make: str, power_train: PowerTrain) -> None:
40 self._make = make
41 self._power_train = power_train
42
43 def start(self) -> None:
44 self._power_train.activate()
45
46 def energy_level(self) -> float:
47 return self._power_train.energy_level()
The composition structure
Vehicle does not know whether it has a combustion engine or an electric motor. It delegates
to the PowerTrain it was given. Adding a hydrogen fuel cell is a new PowerTrain
implementation — Vehicle is untouched.
The interface carries only what every power train shares — activate and energy_level. The
provider-specific operations stay off it on purpose: refuel lives on CombustionEngine and
recharge on ElectricMotor, reached by code that holds the concrete type (a refueling station,
a charger), not through Vehicle. That is the point — Vehicle never has to answer for an
operation half its power trains cannot support, which is exactly what broke the inheritance version.
The rule
Prefer composition and interfaces over inheritance as a default. Use inheritance only when the following are all true: the relationship is genuinely "is-a" (not "has-a" or "can-do"), the base class has real shared implementation that every subclass will use without contradiction, and the base class is stable enough that changing it will not silently break subclasses.
A bank account hierarchy satisfies all three. A vehicle hierarchy with electric cars does not. When in doubt, favor composition: it is always easier to add a base class later than to untangle one.
Interview framing
LLD interviewers frequently introduce a follow-up that requires adding a new variant — "Now add an electric variant" or "Now add a new account type that works differently." If the initial design used inheritance to model behavioral variation, this follow-up requires significant rework. If it used composition, adding the new variant is a new implementing class with no changes to the existing ones. The underlying judgment being probed is whether changeability was considered when the hierarchy was first drawn.