Liskov Substitution Principle

David Morales David Morales
/
SOLID acronym with the letter L highlighted, representing the 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"
end
end
class EmailNotifier < Notifier
def send_message(message, recipient)
puts "Sending email to #{recipient}: #{message}"
{ success: true, channel: :email }
end
end

Now 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 }
end
end

See 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
end
end
notifiers = [EmailNotifier.new, SmsNotifier.new]
service = NotificationService.new(notifiers)
service.broadcast("Hello", "[email protected]")

Running this code results in an error:

Sending email to [email protected]: Hello
ArgumentError: 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 }
end
end

Now 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 }
end
end
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"
end
end

RecurringPaymentProcessor 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
end
end

The 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 }
end
end
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"
end
end

Now 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
end
end
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
end
end

When 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:

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

  1. What does the Liskov Substitution Principle state?

  1. A parent class has a method send_message(message, recipient). A child class overrides it as send_message(message, recipient, country_code) where country_code is mandatory. What type of LSP violation does this represent?

  1. Why is LSP particularly important in Ruby?

  1. Which method should you prefer when checking if an object belongs to a class hierarchy?

  1. 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: