Facebook Pixel

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.

Invest in Yourself
Your new job is waiting. 83% of people that complete the program get a job offer. Unlock unlimited access to all content and features.
Go Pro