DEV Community

Cover image for Understanding SOLID once and for all | Part 03 - (LSP)
Rafael Honório
Rafael Honório

Posted on

Understanding SOLID once and for all | Part 03 - (LSP)

Motivation

Hey everyone, how’s it going? This is the third post of the series where I’m sharing my experience with SOLID. In this article, we’re going to understand a bit more about the Liskov Substitution Principle (LSP) and see some examples.

This might be the hardest concept to understand, but I’ll try to explain it in a simple way to make everything clear.


Summary

This concept was created by Barbara Liskov and Jeannette Wingin 1994 in a paper called "A Behavioral Notion of Subtyping" and the definition says:

Subtypes must be replaceable for their base types without affecting the correctness of the program.

In short, you should be able to replace a base type with its subtype, since the subtype has all the characteristics of the base type, and this shouldn't break the current flow.


Example

Imagine the following situation: we have two classes. A parent class with some properties and methods, and a child class that extends it. If we try to replace the parent class with the child class somewhere in the code, everything should work fine. Here's an example in the image:

The example shows a function that saves the information. In theory, it works because the type substitution doesn’t affect how the code behaves. It respects the contract, whether we’re using inheritance or interface.

LSP says that objects of the child class should be usable in place of objects of the parent class without changing the correct behavior of the program. Let’s check that in the example:

  • Parent Class Contract:

    • The parent class defines properties xpto_01 and xpto_02. The function save_parent() depends on these methods to work correctly.
  • Child Class Behavior:

    • The child class inherits xpto_01 and xpto_02 and adds a new one: xpto_03. If the save_parent() function doesn’t use xpto_03, and the other two properties are implemented in the same way, then it’s all good.
  • Substitution in Function:

    • If save_parent() only uses xpto_01 and xpto_02, and the child class implements them correctly, the substitution works. The new method xpto_03 doesn’t affect the contract.

LSP in practice

Now let’s look at a Go example with the same idea:

In this case, we have an interface called Salvable that defines a contract for saving data. A struct User (like the parent class) and a struct PremiumUser (like the child class) both implement this interface. The child struct has an extra field.

package main

import "fmt"

// Salvable interface defines the base contract
type Salvable interface {
    Save() string
}

type User struct {
    Name string
    Age int
}

// User implements the Salvable interface
func (u *User) Save() string {
    return fmt.Sprintf("Saving user: Name=%s, Age=%d", u.Name, u.Age)
}

// PremiumUser extends User behavior
type PremiumUser struct {
    User
    PremiumLevel int
}

func (pu *PremiumUser) Save() string {
    // Keeps the contract, adding extra info
    return fmt.Sprintf("Saving premium user: Name=%s, Age=%d, PremiumLevel=%d", pu.Name, pu.Age, pu.PremiumLevel)
}

// Function that uses the Salvable interface
func SaveProcess(s Salvable) {
    result := s.Save()
    fmt.Println(result)
}

func main() {
    user := &User{Name: "Jonas", Age: 30}
    premiumUser := &PremiumUser{User: User{Name: "Mary", Age: 25}, PremiumLevel: 3}

    fmt.Println("Processing User:")
    SaveProcess(user)

    fmt.Println("Processing PremiumUser:")
    SaveProcess(premiumUser)
}
Enter fullscreen mode Exit fullscreen mode

Expected output:

Processing User:
Saving user: Name=Jonas, Age=30
Processing PremiumUser:
Saving premium user: Name=Mary, Idade=25, PremiumLevel=3
Enter fullscreen mode Exit fullscreen mode

But what happens if we break the LSP?

package main

import "fmt"

type Salvable interface {
    Save() string
}


type User struct {
    Name string
    Age int
}

func (u *User) Save() string {
    return fmt.Sprintf("Saving user: Name=%s, Age=%d", u.Name, u.Age)
}

type PremiumUser struct {
    User
    PremiumLevel int
}

func (pu *PremiumUser) Save() string {
    // Restriction: only save if PremiumLevel > 0
    if pu.PremiumLevel <= 0 {
        return "Error: Invalid PremiumLevel"
    }
    return fmt.Sprintf("Saving premium user: Name=%s, Age=%d, PremiumLevel=%d", pu.Name, pu.Age, pu.PremiumLevel)
}

func SaveProcess(s Salvable) {
    result := s.Save()
    fmt.Println(result)
}

func main() {
    user := &User{Name: "Jonas", Age: 30}
    premiumUser := &PremiumUser{User: User{Name: "Mary", Age: 25}, PremiumLevel: 3}

    fmt.Println("Processing User:")
    SaveProcess(user)

    fmt.Println("Processing PremiumUser:")
    SaveProcess(premiumUser)
}
Enter fullscreen mode Exit fullscreen mode

Expected output:

Processing User:
Saving user: Name=Jonas, Age=30
Processing PremiumUser:
Error:: Invalid PremiumLevel
Enter fullscreen mode Exit fullscreen mode

In this case, we have some problems:

  • The PremiumUser class adds a restriction (PremiumLevel > 0) that doesn’t exist in Salvable or in User.
  • The SaveProcess function expects Save() to always return a success message, but now it can return an error.
  • This breaks the LSP because replacing Salvable with PremiumUser changes the expected behavior by adding a condition that doesn’t exist in the contract.

Conclusion

In general, this principle helps us reflect on how modules implement behaviors like methods, callbacks, or interfaces. If you need to “force” an implementation just to respect a contract, or change how a method behaves in a subtype, or if replacing types breaks your system, that’s a strong sign the LSP is being violated.

That’s it for now! If you have any questions, leave them in the comments. See you in the next one!

Top comments (0)