Liskov Substitution Principle
Substitution without surprises
The Liskov Substitution Principle, formalized by Barbara Liskov in 1987, states that anywhere a base type is used, a subtype must be substitutable without changing the program's correctness. A subtype must honor the contract of its parent — the same preconditions, the same postconditions, no new exceptions that callers do not expect. When that contract is broken, the subtype is not genuinely substitutable; it only shares a type name with the base.
A violation can be identified by looking for a subclass that throws
UnsupportedOperationException, NotImplementedError, or leaves an inherited method as a no-op.
These patterns indicate that the inheritance hierarchy has been extended past its natural boundary.
A concrete violation: the non-refundable payment method
A payment-processing system defines a PaymentMethod with two operations: charge and refund.
Several concrete types implement it: CreditCard, DebitCard, BankTransfer. Later, a
GiftCard type is added. Gift cards at this merchant cannot be refunded — funds are
non-transferable once loaded. A developer inherits from PaymentMethod and throws on refund.
Every caller that holds a PaymentMethod reference and calls refund — the refund service,
the customer-support tool, the automated reversal job — must now guard against this exception.
The hierarchy declared that any PaymentMethod supports refund, but the GiftCard subtype
does not fulfill that declaration.
1# Bad: GiftCard inherits from PaymentMethod but throws on refund().
2# Callers that accept a PaymentMethod cannot safely call refund().
3from abc import ABC, abstractmethod
4
5class PaymentMethod(ABC):
6 @abstractmethod
7 def charge(self, amount: float) -> None: ...
8 @abstractmethod
9 def refund(self, amount: float) -> None: ...
10
11class CreditCard(PaymentMethod):
12 def charge(self, amount: float) -> None:
13 print(f"Charging ${amount:.2f} to credit card")
14 def refund(self, amount: float) -> None:
15 print(f"Refunding ${amount:.2f} to credit card")
16
17class GiftCard(PaymentMethod):
18 def charge(self, amount: float) -> None:
19 print(f"Redeeming ${amount:.2f} from gift card")
20 def refund(self, amount: float) -> None:
21 # Violates LSP: callers holding a PaymentMethod cannot call this safely
22 raise NotImplementedError("Gift cards are non-refundable")
23
24# Every caller now needs a special case — the subtype broke the contract:
25def process_refund(method: PaymentMethod, amount: float) -> None:
26 if isinstance(method, GiftCard): # ← caller forced to know the subtype
27 return
28 method.refund(amount)
The fix: segregate by capability
The root problem is that refund does not belong in the base abstraction — it is a capability
that only some payment methods have. The fix is to split the hierarchy: a PaymentMethod can
only charge. A RefundablePaymentMethod extends that with the additional refund operation.
GiftCard implements the narrow base. CreditCard and BankTransfer implement the wider one.
Callers that need to issue refunds declare that they require a RefundablePaymentMethod. The
refund service, the customer-support tool, and the reversal job are explicit about the capability
they depend on. A caller that holds a plain PaymentMethod will not encounter an unexpected
exception, because GiftCard no longer implements an operation it cannot support.