Facebook Pixel

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.

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