DEV Community

Muhammad Salem
Muhammad Salem

Posted on

Liskov Substitution Principle (LSP): Do You Really Understand It?

Violating the Liskov Substitution Principle (LSP) can cause significant issues in software systems. The LSP states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. If a subclass cannot fulfill the contract established by its base class, it can lead to unpredictable behavior, bugs, and maintenance challenges.

Let's explore a real-world scenario where the Liskov Substitution Principle (LSP) is violated, and examine the consequences in detail. This example will be based on a financial software system for a bank, focusing on account management.

Scenario: Online Banking System

Imagine we're developing an online banking system that handles various types of accounts. We start with a base class BankAccount and two derived classes: SavingsAccount and CheckingAccount.

Here's the initial design:

public class BankAccount
{
    public decimal Balance { get; protected set; }

    public virtual void Deposit(decimal amount)
    {
        if (amount > 0)
        {
            Balance += amount;
        }
    }

    public virtual void Withdraw(decimal amount)
    {
        if (amount > 0 && Balance >= amount)
        {
            Balance -= amount;
        }
    }
}

public class SavingsAccount : BankAccount
{
    public override void Withdraw(decimal amount)
    {
        // Savings accounts have a withdrawal limit of $1000
        if (amount > 0 && Balance >= amount && amount <= 1000)
        {
            Balance -= amount;
        }
    }
}

public class CheckingAccount : BankAccount
{
    public decimal OverdraftLimit { get; set; } = 100;

    public override void Withdraw(decimal amount)
    {
        if (amount > 0 && (Balance + OverdraftLimit) >= amount)
        {
            Balance -= amount;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's say we have a method in our transaction processing system that uses these account types:

public class TransactionProcessor
{
    public void ProcessWithdrawal(BankAccount account, decimal amount)
    {
        account.Withdraw(amount);
        // Additional logic like logging, notifications, etc.
    }
}
Enter fullscreen mode Exit fullscreen mode

The violation of LSP occurs in the SavingsAccount class. While it seems logical to add a withdrawal limit for savings accounts, this implementation violates the LSP because it strengthens the preconditions of the Withdraw method.

Potential Problems:

  1. Unexpected Behavior: Consider this code:
   BankAccount account = new SavingsAccount();
   account.Deposit(2000);
   account.Withdraw(1500);
Enter fullscreen mode Exit fullscreen mode

A programmer expecting this to work (since a BankAccount should be able to withdraw any amount up to its balance) would be surprised when the withdrawal doesn't occur.

  1. Breaking Client Code: Any existing code that works with BankAccount objects might break when given a SavingsAccount. For example:
   void TransferFunds(BankAccount from, BankAccount to, decimal amount)
   {
       from.Withdraw(amount);
       to.Deposit(amount);
   }
Enter fullscreen mode Exit fullscreen mode

This method would fail unexpectedly if from is a SavingsAccount and amount is over $1000, even if the account has sufficient funds.

  1. Violating Client Expectations: Clients of the BankAccount class expect certain behavior. The SavingsAccount class changes this behavior in a way that's not immediately obvious, leading to potential bugs and confusion.

  2. Testing Difficulties: Unit tests written for the BankAccount class may fail when run against SavingsAccount objects, requiring separate test suites and complicating the testing process.

  3. Maintenance Challenges: As the system grows, maintaining consistent behavior across all account types becomes increasingly difficult. Developers might need to add special checks or conditions throughout the codebase to handle SavingsAccount differently.

  4. Scalability Issues: If we decide to add more account types in the future, we might be tempted to add more special cases and restrictions, further violating LSP and making the system increasingly complex and prone to errors.

To adhere to the Liskov Substitution Principle, we need to rethink our design. Here's one possible solution:

public abstract class BankAccount
{
    public decimal Balance { get; protected set; }

    public void Deposit(decimal amount)
    {
        if (amount > 0)
        {
            Balance += amount;
        }
    }

    public abstract bool CanWithdraw(decimal amount);

    public void Withdraw(decimal amount)
    {
        if (CanWithdraw(amount))
        {
            Balance -= amount;
        }
        else
        {
            throw new InvalidOperationException("Withdrawal not allowed");
        }
    }
}

public class SavingsAccount : BankAccount
{
    public override bool CanWithdraw(decimal amount)
    {
        return amount > 0 && Balance >= amount && amount <= 1000;
    }
}

public class CheckingAccount : BankAccount
{
    public decimal OverdraftLimit { get; set; } = 100;

    public override bool CanWithdraw(decimal amount)
    {
        return amount > 0 && (Balance + OverdraftLimit) >= amount;
    }
}
Enter fullscreen mode Exit fullscreen mode

In this revised design:

  1. We've moved the withdrawal logic to the base class.
  2. We've introduced an abstract CanWithdraw method that each derived class must implement.
  3. The Withdraw method in the base class uses CanWithdraw to determine if a withdrawal is allowed.

This design adheres to LSP because:

  • Subclasses don't change the behavior of the Withdraw method itself.
  • The preconditions for withdrawal are encapsulated in the CanWithdraw method, which can be overridden without violating the expectations set by the base class.
  • Client code can work with any BankAccount object without unexpected behavior.

By adhering to LSP, we've created a more robust, maintainable, and extensible design. This approach allows us to add new account types easily, each with its own withdrawal rules, without breaking existing code or violating the expectations set by the base BankAccount class.

Let's break down the potential problems with other real-world scenarios to help you form a deep understanding.

Scenario 1: Payment Processing System

Context

In an e-commerce platform, you have a base class PaymentMethod, which includes a method ProcessPayment() that processes a payment for an order. Different payment methods, such as CreditCardPayment, PayPalPayment, and GiftCardPayment, inherit from this base class.

Violation of LSP

The GiftCardPayment class overrides the ProcessPayment() method but, unlike the other payment methods, it doesn't validate the payment or handle transaction failures correctly. Instead, it skips these steps entirely because gift card payments are assumed to always be valid.

Potential Problems

  1. Unexpected Behavior: In parts of the code where PaymentMethod objects are expected to behave consistently, substituting a GiftCardPayment causes issues. For example, when the system expects a ProcessPayment() call to validate and possibly fail, the GiftCardPayment class always succeeds. This could result in orders being marked as paid even when they shouldn't be.

  2. Testing and Debugging Difficulty: When debugging or testing the payment processing system, it's harder to predict the behavior of the GiftCardPayment class because it doesn't follow the same rules as other payment methods. Test cases that assume consistent behavior across all payment methods fail unpredictably, making the system harder to maintain and extend.

  3. Code Duplication: To handle this inconsistency, developers might start writing special cases or conditional logic throughout the system, checking if the payment method is a GiftCardPayment and then adjusting the logic. This leads to code duplication, making the system fragile and harder to modify.

Solution

Ensure that GiftCardPayment adheres to the same contract as other payment methods. Even if the validation step is trivial, it should still be implemented to maintain consistency across the PaymentMethod hierarchy. Alternatively, if gift card payments require fundamentally different logic, it might make sense to restructure the class hierarchy.

Scenario 2: Vehicle Rental System

Context

A vehicle rental system has a base class Vehicle with a method StartEngine(). The system includes several vehicle types, such as Car, Truck, and Bicycle.

Violation of LSP

The Bicycle class inherits from Vehicle but overrides the StartEngine() method to throw an exception, as bicycles don't have engines.

Potential Problems

  1. Unexpected Failures: The system assumes that all Vehicle objects can start their engines, so when it tries to start the engine of a Bicycle, it encounters an unexpected exception. This leads to runtime errors and crashes in parts of the system that rely on the Vehicle interface.

  2. Violation of Client Expectations: If client code is written to handle any Vehicle type and expects StartEngine() to work, introducing a Bicycle into the system violates that expectation. This undermines the reliability of the code, as the client cannot trust that all Vehicle objects will behave as expected.

  3. Increased Complexity: Developers might need to add checks throughout the system to ensure they're not calling StartEngine() on a Bicycle. This increases complexity, making the codebase harder to maintain and more prone to bugs. The elegance of polymorphism is lost, as now the system relies on explicit type checks, defeating the purpose of inheritance.

Solution

Instead of making Bicycle inherit from Vehicle, consider using a different design approach. For instance, you could create a separate NonMotorizedVehicle class or interface that doesn't include engine-related methods. This way, the class hierarchy respects the LSP, and all Vehicle objects can safely start their engines.

Scenario 3: Inventory Management System

Context

In an inventory management system, there is a base class Product with a method ApplyDiscount() that reduces the price of the product by a given percentage. Different product types, such as Electronics, Clothing, and GiftCard, inherit from this class.

Violation of LSP

The GiftCard class overrides ApplyDiscount() to do nothing, as gift cards cannot be discounted. However, it still inherits from Product because it shares some common properties, like a name and price.

Potential Problems

  1. Inconsistent Behavior: In parts of the system where a list of Product objects is processed, applying a discount across the board will work for most products but fail silently for GiftCard objects. This inconsistency can lead to confusion and bugs, especially when users expect all products to reflect the discount.

  2. Unexpected Outcomes: If the system generates reports or invoices that assume all products have had discounts applied, GiftCard objects might appear incorrectly priced, leading to discrepancies in financial records.

  3. Violation of Open/Closed Principle: To handle the special case of GiftCard, developers might start introducing conditional logic wherever discounts are applied, checking if a product is a GiftCard before applying the discount. This violates the Open/Closed Principle, as the system must be modified to accommodate new product types instead of simply extending existing functionality.

Solution

Instead of inheriting from Product, GiftCard could be treated as a different entity with its own class, separate from products that can be discounted. Alternatively, you could use a strategy pattern where the discount behavior is injected, allowing GiftCard to refuse discounts without violating LSP.

Scenario 4: Social Media Application

Context

In a social media application, there is a base class Post with a method Share() that allows users to share the post on their timeline. Different types of posts, such as TextPost, ImagePost, and SponsoredPost, inherit from Post.

Violation of LSP

The SponsoredPost class overrides Share() to throw an exception, as sponsored posts cannot be shared by regular users.

Potential Problems

  1. User Experience Issues: When users attempt to share a sponsored post, they encounter unexpected errors or crashes, leading to a frustrating user experience. The application fails to provide a consistent interface for all posts.

  2. Client Code Breakage: Any code that works with Post objects expects the Share() method to function uniformly. Introducing a SponsoredPost that can't be shared breaks this expectation and forces developers to add checks or workarounds, making the codebase more brittle and complex.

  3. Design Inflexibility: If the system needs to accommodate more special cases like SponsoredPost, the design becomes increasingly inflexible, with more exceptions and conditional logic scattered throughout the code.

Solution

A better approach might be to handle sharing restrictions outside the Share() method itself, such as through user permissions or a validation layer. Alternatively, if sponsored posts are fundamentally different from regular posts, they could be part of a different class hierarchy.

Key Takeaways

  1. Unpredictable Behavior: Violating LSP leads to unpredictable behavior, where subclasses do not behave as expected when substituted for their base classes.

  2. Increased Complexity: Code complexity increases as developers add special cases and checks to handle situations where LSP is violated, leading to harder-to-maintain systems.

  3. Fragile Codebase: A codebase that violates LSP becomes fragile, with changes in one part of the system potentially leading to cascading failures elsewhere.

  4. Testing and Debugging Nightmares: It becomes harder to test and debug the system because the behavior of objects is inconsistent, and test cases may fail in unexpected ways.

By understanding these real-world scenarios, you can appreciate the importance of adhering to the Liskov Substitution Principle and how it helps maintain a robust, maintainable, and predictable system.

Top comments (0)