SOLID Principles in Depth: The Liskov Substitution Principle (LSP)
Continuing our journey through the SOLID principles of object-oriented design, we now arrive at the Liskov Substitution Principle (LSP)—a concept that’s easy to misinterpret, but incredibly powerful once you understand its real impact on software quality.
Let’s unpack what it means, why it matters, and how to avoid the subtle design traps it protects us from.
What is the Liskov Substitution Principle?
Definition (Barbara Liskov, 1987):
“Subtypes must be substitutable for their base types without altering the correctness of the program.”
In simpler terms:
If class
It ensures that subclasses don’t just “fit” syntactically, but also preserve the expectations of the base class.
Why LSP Matters
- Ensures reliability when using inheritance.
- Supports polymorphism without introducing bugs.
- Keeps code intuitive by avoiding surprise behavior in subclasses.
- Improves reusability by allowing you to use any subclass confidently.
Classic Violation of LSP: The Rectangle & Square Problem
Let’s say we have a
class Rectangle: def __init__(self, width, height): self.width = width self.height = height def set_width(self, width): self.width = width def set_height(self, height): self.height = height def area(self): return self.width * self.height
Now we define a
class Square(Rectangle): def set_width(self, width): self.width = width self.height = width # Force height = width def set_height(self, height): self.width = height self.height = height
At first glance, this seems okay. A square is-a rectangle, right? But let’s test it:
def resize_and_print_area(rect): rect.set_width(5) rect.set_height(10) print(rect.area()) resize_and_print_area(Rectangle(2, 3)) # Output: 50 (5 * 10) resize_and_print_area(Square(2)) # Output: 100 (10 * 10) ❌
The square breaks expectations. The caller assumes width and height can be set independently. This violates LSP.
LSP-Compliant Design: Composition over Inheritance
To fix this, we can avoid inheritance and model
class Rectangle: def __init__(self, width, height): self.width = width self.height = height def area(self): return self.width * self.height class Square: def __init__(self, size): self.size = size def area(self): return self.size * self.size
Now both types are separate abstractions with clear, predictable behavior.
Real-World Example: Payment Processing in an E-Commerce System
Imagine you're building an e-commerce microservice that integrates with multiple payment providers.
You define a base class:
class PaymentProvider: def authorize(self, amount): raise NotImplementedError def capture(self, transaction_id): raise NotImplementedError
You then implement a few providers:
class StripeProvider(PaymentProvider): def authorize(self, amount): # Call Stripe API return "stripe_txn_123" def capture(self, transaction_id): # Capture payment using Stripe return True
Then you add PayPal:
class PayPalProvider(PaymentProvider): def authorize(self, amount): # PayPal doesn't support separate authorize and capture raise NotImplementedError("PayPal uses direct charge") def capture(self, transaction_id): raise NotImplementedError("Not supported")
LSP Violation
You now cannot safely substitute
def checkout(provider: PaymentProvider): txn_id = provider.authorize(100) # This fails for PayPal provider.capture(txn_id)
Code that expects a
LSP-Compliant Design: Split Interfaces Based on Behavior
The issue is that your abstraction was too broad. Fix it by splitting responsibilities:
class PaymentAuthorizer: def authorize(self, amount): raise NotImplementedError class PaymentCapturer: def capture(self, transaction_id): raise NotImplementedError
Stripe supports both:
class StripePayment(PaymentAuthorizer, PaymentCapturer): def authorize(self, amount): return "stripe_txn_123" def capture(self, transaction_id): return True
PayPal supports direct charge only:
class PayPalDirectCharge: def charge(self, amount): # PayPal's one-step charge return "paypal_txn_456"
Then your application logic becomes cleaner and safer:
def checkout_two_step(authorizer: PaymentAuthorizer, capturer: PaymentCapturer): txn_id = authorizer.authorize(100) capturer.capture(txn_id) def checkout_one_step(direct_charge_provider): txn_id = direct_charge_provider.charge(100)
Summary of the Real-World LSP Lesson
| Incorrect Design (LSP Violation) | Correct Design (LSP Compliant) |
|---|---|
| Subclass doesn’t implement full behavior | Subclass only implements supported behavior |
| Code breaks when a subclass is swapped | Any subclass can be used safely and predictably |
| One interface forces all subclasses to conform | Multiple focused interfaces separate responsibilities |
Where This Happens Often
- Cloud provider abstractions (e.g., AWS S3 vs Google Cloud Storage APIs)
- Database drivers (some support transactions, some don’t)
- Shipping providers (some offer tracking updates, others don’t)
- Social login providers (different user data returned from Google, Apple, Facebook)
LSP Guidelines in Practice
To follow LSP:
- Subclasses should honor contracts of the base class.
- Don’t override methods in a way that changes expected behavior.
- Avoid “dummy” implementations or raising exceptions from overridden methods.
- Think in terms of behavioral substitutability, not just structure.
Common Signs You're Violating LSP
- You override methods just to raise exceptions
- A subclass “disables” some features of the superclass
- You rely on type checks (if isinstance) inside polymorphic code
- Swapping a subclass causes test failures
Pro Tips
- Always write tests that pass both the parent and child into the same function.
- Consider using interfaces or abstract classes to define clear expectations.
- Rethink your inheritance model when “is-a” doesn’t fully apply.
Conclusion
The Liskov Substitution Principle reminds us that inheritance is more than just code reuse—it’s about preserving behavioral contracts. Subclasses should honor the expectations set by their parent classes to keep your code safe, consistent, and extendable.
By applying LSP, you build systems that are more predictable, easier to reason about, and robust to change.
Up Next: The Interface Segregation Principle—learn how to keep your interfaces lean and focused!