Liskov Substitution Principle
The Liskov Substitution Principle (LSP) is perhaps the most abstract of the five SOLID principles. Its name comes from Barbara Liskov, who introduced the concept in a conference back in 1987. Unlike other principles that can be grasped intuitively, LSP requires us to pause and really think about its definition.
The Definition
The principle states that:
A program should be able to use any instance of a child class in place of an instance of the parent class without affecting the expected behavior.
Let’s break this down. We are talking about inheritance: parent classes and child classes. The principle tells us that if our code works with a parent class, it should work exactly the same way if we pass it an instance of any of its subclasses. There shouldn’t be any errors or strange side effects.
Ruby and Duck Typing
Ruby is a dynamically typed language that relies on duck typing. This means we don’t verify types at compile time. We care about what an object can do, not what it is. In other words, if an object responds to the quack method, we treat it like a duck, regardless of its class.
This philosophy might lead us to believe that LSP is less relevant in Ruby. However, since Ruby doesn’t force us to respect type contracts, the responsibility falls entirely on us as developers. LSP violations in Ruby don’t blow up at compile time (because there isn’t one); instead, they manifest as subtle bugs in production.
Structural Violation: When the Interface Breaks
Structural violations are easy to spot because Ruby raises exceptions.
Imagine a notification system:
class Notifier def send_message(message, recipient) raise NotImplementedError, "Subclasses must implement this method" endend
class EmailNotifier < Notifier def send_message(message, recipient) puts "Sending email to #{recipient}: #{message}" { success: true, channel: :email } endendNow we add an SMS notifier:
class SmsNotifier < Notifier def send_message(message, recipient, country_code) puts "Sending SMS to +#{country_code} #{recipient}: #{message}" { success: true, channel: :sms } endendSee the difference? SmsNotifier requires a mandatory third parameter.
Now, let’s implement a service that uses the notifiers polymorphically:
class NotificationService def initialize(notifiers) @notifiers = notifiers end
def broadcast(message, recipient) @notifiers.each do |notifier| notifier.send_message(message, recipient) end endend
notifiers = [EmailNotifier.new, SmsNotifier.new]service = NotificationService.new(notifiers)
Running this code results in an error:
Sending email to [email protected]: HelloArgumentError: wrong number of arguments (given 2, expected 3)The solution is to maintain compatibility with the parent interface:
class SmsNotifier < Notifier def initialize(default_country_code: "1") @default_country_code = default_country_code end
def send_message(message, recipient, country_code: nil) code = country_code || @default_country_code puts "Sending SMS to +#{code} #{recipient}: #{message}" { success: true, channel: :sms } endendNow the parameters of SmsNotifier are compatible with its parent. The country code can be specified optionally, or it can fall back to the default value. This way, substitution works correctly.
An important detail: the optional argument isn’t just a patch to “comply with LSP.” The default value must be a conscious design decision that keeps the object fully functional. If SmsNotifier needed the country code to operate correctly and we simply made it optional to satisfy the interface, we would be introducing a bug rather than solving the problem.
Behavioral Violation: When the Contract is Betrayed
Behavioral violations are more insidious: the code runs without errors, but it does something unexpected.
Consider a payment processing system:
class PaymentProcessor def process(amount) { success: true, amount: amount, processed_at: Time.now } endend
class RecurringPaymentProcessor < PaymentProcessor def process(amount) result = super schedule_next_payment(amount) result end
private
def schedule_next_payment(amount) puts "Next payment of $#{amount} scheduled" endendRecurringPaymentProcessor respects the interface: it accepts the same arguments and returns the same structure. But it introduces a significant side effect.
Imagine a standard checkout module in an online store. The user adds products to the cart, enters their payment details, and completes the purchase. There is no mention of subscriptions or recurring payments in the interface:
class CartCheckoutService def initialize(processor) @processor = processor end
def complete_purchase(cart, user) result = @processor.process(cart.total) send_confirmation_email(user) result endendThe service accepts any processor polymorphically. Now, imagine that by mistake, the wrong processor is injected:
checkout_service = CartCheckoutService.new(RecurringPaymentProcessor.new)
checkout_service.complete_purchase(cart, user)If the user just wanted to buy a t-shirt, they now have a t-shirt subscription.
Inheritance suggests that both processors are substitutable. But in the context of using a checkout for one-time purchases, scheduling future payments is not contemplated. This is the side effect—the user never saw a subscription option nor accepted it, yet they are now subscribed.
This type of violation is particularly dangerous because it goes unnoticed until a customer complains about unexpected charges.
The solution involves making the difference explicit:
class PaymentProcessor def process(amount) { success: true, amount: amount, processed_at: Time.now, recurring: false } endend
class RecurringPaymentProcessor < PaymentProcessor def initialize(interval: :monthly) @interval = interval end
def process(amount) result = super result[:recurring] = true result[:next_payment] = schedule_next_payment(amount) result end
private
def schedule_next_payment(amount) puts "Next payment of $#{amount} scheduled" endendNow the result clearly communicates what happened. The client code can check result[:recurring] and act accordingly.
The Pragmatic Perspective
At this point, it’s worth introducing some nuance. Jeremy Evans, author of Polished Ruby Programming, offers a pragmatic view: the principle is useful as a general guide, but we shouldn’t be dogmatic in its application.
His argument is as follows: strictly speaking, any subtype that behaves differently from its supertype could be considered an LSP violation. And if subtypes can’t have different behavior, what is the utility of creating subtypes?
Evans points out a specific case where Ruby tends to break LSP: the use of instance_of? versus kind_of?.
def perform_action(obj) if obj.instance_of?(User) grant_access # Only executes for the User class else deny_access endend
class AdminUser < User; end
perform_action(AdminUser.new) # => deny_access (surprise!)Using instance_of? breaks substitutability because it discriminates against subclasses. The recommendation is to use kind_of? (or its alias is_a?) when we need to check types:
def perform_action(obj) if obj.kind_of?(User) grant_access # Works for User and all its subclasses else deny_access endendWhen to Break the Rules
Ruby, by philosophy, trusts that the programmer will do the right thing. It doesn’t impose restrictions because it assumes we understand the consequences of our decisions.
There are situations where consciously violating LSP is acceptable:
- When the subclass has a specialized purpose and will never be used polymorphically with its parent. If
AdminPaymentProcessoris only used in specific administrative contexts, adding extra parameters might be reasonable. - When the additional behavior is documented and expected in that context. If the whole team knows that certain processors have side effects, and the code is designed to handle them, the “violation” is actually expected and documented behavior.
The key lies in intentionality. It is one thing to violate LSP out of carelessness, and another to do so knowingly, understanding the implications and documenting them.
Conclusion
The Liskov Substitution Principle invites us to think about the expectations we create when designing class hierarchies. In Ruby, where duck typing grants freedom, the responsibility lies with the developer.
Structural violations, where the method signature changes, are obvious and easy to fix. Behavioral violations, where the method does something unexpected, require more attention to design and better documentation.
My recommendation: use LSP as a lens to review your code. When you create a subclass, ask yourself if it can substitute its parent in all contexts where the parent is used. If the answer is no, consider whether that is intentional and documented, or if you have introduced a ticking time bomb into your code.
Test your knowledge
-
What does the Liskov Substitution Principle state?
-
A parent class has a method
send_message(message, recipient). A child class overrides it assend_message(message, recipient, country_code)wherecountry_codeis mandatory. What type of LSP violation does this represent?
-
Why is LSP particularly important in Ruby?
-
Which method should you prefer when checking if an object belongs to a class hierarchy?
-
A subclass has the same method signature as its parent but introduces a hidden side effect (like scheduling recurring payments in a one-time checkout flow). This is: