Liskov Substitution Principle (LSP)
The "L" in the SOLID principles is the Liskov Substitution Principle or the LSP.
In this BLOG, I had mentioned the definition of the LSP.
Subtypes must be substitutable for their base types without altering the correctness of the program. Essentially, derived classes should extend the behaviour of a base class without changing its expected behaviour.
Let us try to understand the above complicated definition with a nice code example.
Assume we have registered and guest users paying monthly water supply bills at their homes to the local corporation.
We’ll explore a violation of the Liskov Substitution Principle (LSP) and then provide a solution that adheres to LSP. We'll also discuss what LSP actually solves.
To solve the above problem we begin with a PaymentService class that has a payment processing method called "processPayment" with "amount" as an argument.
PaymentService is the base class.
class PaymentService {
processPayment(amount: number): void {
console.log(`Processing payment of ₹${amount}`);
// Generic payment processing logic
}
}
In our scenario, both registered and guest users can pay their water supply bills. Registered users have access to discounts and loyalty points, while guest users do not.
We now define both the subclasses - RegisteredUserPaymentService and GuestUserPaymentService.
class RegisteredUserPaymentService extends PaymentService {
processPayment(amount: number): void {
const discountedAmount = this.applyDiscount(amount);
this.addLoyaltyPoints(discountedAmount);
console.log(`Processing payment of ₹${discountedAmount} for registered user`);
// Registered user-specific payment logic
}
private applyDiscount(amount: number): number {
return amount * 0.9; // 10% discount for registered users
}
private addLoyaltyPoints(amount: number): void {
// Registered user-specific payment logic
const points = Math.floor(amount / 10); // Earn 1 loyalty point for every ₹10 spent
console.log(`Added ${points} loyalty points to the registered user's account.`);
}
}
class GuestUserPaymentService extends PaymentService {
processPayment(amount: number): void {
console.log(`Processing payment of ₹${amount} for guest user`);
// Guest user-specific payment logic
console.log(`Guest users do not receive discounts or loyalty points.`);
}
protected adjustAmount(amount: number): number {
// No discount applied for guest users, so return the original amount
return amount;
}
}
RegisteredUserPaymentService: This subclass handles registered users by giving them a discount and loyalty points.
GuestUserPaymentService: This subclass handles guest users, who don’t get discounts or loyalty points.
At first glance, everything might seem fine. However, there's a subtle issue here:
Process Differences:
The RegisteredUserPaymentService class applies a discount and adds loyalty points, which isn't applicable to guest users.
The GuestUserPaymentService class doesn't apply these benefits, but it still has to provide a processPayment method because it's inheriting from PaymentService.
If someone wrote code that works with the base class PaymentService, they might expect that substituting any subclass (like RegisteredUserPaymentService or GuestUserPaymentService) wouldn't change the behavior of the application in unexpected ways.
However, because processPayment behaves differently depending on the subclass, this substitution might lead to issues. For example, a method that processes payments might try to apply loyalty points or discounts, assuming all users have those features, leading to unexpected behaviour when a GuestUserPaymentService is used.
Let's take a look at how the naive implementation might cause problems when processPayment behaves differently depending on the subclass.
Imagine we have a function that processes payments for any PaymentService object. The function doesn't know whether it's dealing with a registered user or a guest user— it just knows it has a PaymentService object.
function handlePayment(paymentService: PaymentService, amount: number): void {
paymentService.processPayment(amount);
}
// Let's use this function with both types of payment services
const registeredPayment = new RegisteredUserPaymentService();
const guestPayment = new GuestUserPaymentService();
// Expected: Processes with discount, adds loyalty points
handlePayment(registeredPayment, 1000);
// Expected: Processes without discount or loyalty points
handlePayment(guestPayment, 1000);
Output
Processing payment of ₹900 for registered user
Added 90 loyalty points to the registered user's account.
Processing payment of ₹1000 for guest user
Guest users do not receive discounts or loyalty points.
Problems:
Different Behaviours: The processPayment method in RegisteredUserPaymentService does more than just process the payment. It also applies a discount and adds loyalty points. However, the GuestUserPaymentService doesn’t do these additional steps.
Unexpected Results: If someone expected all subclasses of PaymentService to behave similarly (just processing the payment), they might be surprised that the registered user gets a discount and loyalty points, while the guest user does not. This can lead to bugs or unintended behavior when the base class is substituted with one of its subclasses.
This inconsistency violates the Liskov Substitution Principle (LSP), which states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
When you create a base class, the expectation is that any subclass should be able to replace the base class without changing how the rest of the code works. In our case, this doesn’t happen. This inconsistency makes your code harder to understand, test, and extend.
Introducing Liskov Substitution Principle (LSP):
Liskov Substitution Principle (LSP) is a fancy way of saying:
"If you have a base class and a bunch of subclasses, you should be able to use any of those subclasses in place of the base class without breaking your code."
In simpler terms: All subclasses should behave in a way that doesn't surprise anyone using the base class.
How to Fix This?
To adhere to LSP, you need to ensure that every subclass of PaymentService behaves consistently when it comes to processing payments. Here's how we can do it:
Separate Common and Specific Behaviour:
Ensure that all subclasses provide the same basic functionality (processPayment) without unexpected variations.
Move any user-specific logic (like applying discounts or adding loyalty points) out of the processPayment method into other methods that can be optionally called if needed.
Make Subclasses Interchangeable:
- Ensure that when you substitute the base class (PaymentService) with any of its subclasses, the core behaviour remains the same. For example, processPayment should always process the payment in a consistent way.
Let's refactor the code to adhere to the Liskov Substitution Principle (LSP). The goal is to ensure that any subclass of PaymentService can be used interchangeably without unexpected behaviour.
Step 1: Simplify PaymentService
First, we define the PaymentService class to focus only on the core functionality of processing payments. We'll keep it simple and consistent, so all subclasses follow the same pattern.
class PaymentService {
processPayment(amount: number): void {
console.log(`Processing payment of ₹${amount}`);
// Core payment processing logic that applies to all users
}
}
Step 2: Refactor Subclasses to be LSP-Compliant
Next, we refactor the subclasses to ensure they don’t change the core behavior of processPayment. Instead, they can extend or add features through additional methods.
Refactored RegisteredUserPaymentService:
class RegisteredUserPaymentService extends PaymentService {
processPayment(amount: number): void {
const discountedAmount = this.applyDiscount(amount);
super.processPayment(discountedAmount);
this.addLoyaltyPoints(discountedAmount);
}
private applyDiscount(amount: number): number {
return amount * 0.9; // 10% discount for registered users
}
private addLoyaltyPoints(amount: number): void {
const points = Math.floor(amount / 10); // Earn 1 loyalty point for every ₹10 spent
console.log(`Added ${points} loyalty points to the registered user's account.`);
}
}
Key Changes:
We call super.processPayment to ensure the core payment logic is consistent.
We add user-specific logic (like discounts and loyalty points) before and after calling the core payment logic
Refactored GuestUserPaymentService:
class GuestUserPaymentService extends PaymentService {
processPayment(amount: number): void {
super.processPayment(amount);
console.log(`Guest users do not receive discounts or loyalty points.`);
}
}
Key Changes:
The GuestUserPaymentService also calls super.processPayment to ensure consistency.
Guest-specific logic (e.g., the message about no discounts) is added after processing the payment.
Let’s add a new subclass for corporate users. Corporate users might have their own specific logic, but they should still follow the same consistent pattern.
Step 3: Introduce CorporateUserPaymentService
class CorporateUserPaymentService extends PaymentService {
processPayment(amount: number): void {
const discountedAmount = this.applyCorporateDiscount(amount);
super.processPayment(discountedAmount);
this.generateInvoice(discountedAmount);
}
private applyCorporateDiscount(amount: number): number {
return amount * 0.85; // 15% discount for corporate users
}
private generateInvoice(amount: number): void {
console.log(`Invoice generated for ₹${amount} for corporate user.`);
}
}
Step 4: Testing LSP Compliance
Now, let’s test all these services using the same handlePayment
function:
function handlePayment(paymentService: PaymentService, amount: number): void {
paymentService.processPayment(amount);
}
const registeredPayment = new RegisteredUserPaymentService();
const guestPayment = new GuestUserPaymentService();
const corporatePayment = new CorporateUserPaymentService();
// Consistent behavior, with discounts and points for registered users
handlePayment(registeredPayment, 1000);
// Consistent behavior, no discounts for guest users
handlePayment(guestPayment, 1000);
// Consistent behavior, with corporate discounts and invoice generation
handlePayment(corporatePayment, 1000);
Output:
Processing payment of ₹900 for registered user
Added 90 loyalty points to the registered user's account.
Processing payment of ₹1000
Guest users do not receive discounts or loyalty points.
Processing payment of ₹850 for corporate user
Invoice generated for ₹850 for corporate user.
Conclusion:
LSP Compliance: By refactoring the subclasses to call the base processPayment method (using super), we ensure that the core behavior is consistent across all subclasses.
Extensibility: Now, you can add new subclasses (like CorporateUserPaymentService) without worrying about breaking existing code. Each subclass adds or modifies behavior in a way that doesn't surprise the rest of the codebase.
This approach makes the code easier to maintain, extend, and understand—essentially adhering to the Liskov Substitution Principle.
The code for review can be found on this GITHUB LINK(LSP)