Practical SOLID in Golang: Liskov Substitution Principle

- 9 minutes read - 1876 words

We continue our journey through the SOLID principles by presenting the one with the most complex definition: The Liskov Substitution Principle.

I’m not really a fan of reading. Often, when I do read, I find myself losing track of the text’s topic for the past few minutes. Many times, I’ll go through an entire chapter without really grasping what it was all about in the end. It can be frustrating when I’m trying to focus on the content, but I keep realizing I need to backtrack. That’s when I turn to various types of media to learn about a topic. The first time I encountered this reading issue was with the SOLID principle, specifically the Liskov Substitution Principle. Its definition was (and still is) too complicated for my taste, especially in its formal format. As you can guess, LSP represents the letter “L” in the word SOLID. It’s not difficult to understand, although a less mathematical definition would be appreciated.

When we do not respect The Liskov Substitution

The first time we encountered this principle was in 1988, thanks to Barbara Liskov. Later, Uncle Bob shared his perspective on this topic in a paper and eventually included it as one of the SOLID principles. Let’s take a look at what it says:

Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.

Well, good luck with that definition.

No, seriously, what kind of definition is this? Even as I write this article, I’m still struggling to fully grasp this definition, despite my fundamental understanding of LSP. Let’s give it another shot:

If S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program.

Okay, this is a bit clearer now. If ObjectA is an instance of ClassA, and ObjectB is an instance of ClassB, and ClassB is a subtype of ClassA – if we use ObjectB instead of ObjectA somewhere in the code, the application’s functionality must not break. We’re talking about classes and inheritance here, two concepts that aren’t prominent in Go. However, we can still apply this principle using interfaces and polymorphism.

Wrong implementation of Update method

type User struct {
	ID uuid.UUID
	//
	// some fields
	//
}

type UserRepository interface {
	Update(ctx context.Context, user User) error
}

type DBUserRepository struct {
	db *gorm.DB
}

func (r *DBUserRepository) Update(ctx context.Context, user User) error {
	return r.db.WithContext(ctx).Delete(user).Error
}

In this code example, we can see one that’s quite absurd and far from best practices. Instead of updating the User in the database, as the Update method claims, it actually deletes it. But that’s precisely the point here. We have an interface, UserRepository, followed by a struct, DBUserRepository. While this struct technically implements the interface, it completely diverges from what the interface is supposed to do. In fact, it breaks the functionality of the interface rather than fulfilling its expectations. This highlights the essence of the Liskov Substitution Principle (LSP) in Go: a struct must not violate the intended behavior of the interface.

Now, let’s explore some less ridiculous examples:

Multiple implementations of UserRepository

type UserRepository interface {
	Create(ctx context.Context, user User) (*User, error)
	Update(ctx context.Context, user User) error
}

type DBUserRepository struct {
	db *gorm.DB
}

func (r *DBUserRepository) Create(ctx context.Context, user User) (*User, error) {
	err := r.db.WithContext(ctx).Create(&user).Error
	return &user, err
}

func (r *DBUserRepository) Update(ctx context.Context, user User) error {
	return r.db.WithContext(ctx).Save(&user).Error
}

type MemoryUserRepository struct {
	users map[uuid.UUID]User
}

func (r *MemoryUserRepository) Create(_ context.Context, user User) (*User, error) {
	if r.users == nil {
		r.users = map[uuid.UUID]User{}
	}
	user.ID = uuid.New()
	r.users[user.ID] = user
	
	return &user, nil
}

func (r *MemoryUserRepository) Update(_ context.Context, user User) error {
	if r.users == nil {
		r.users = map[uuid.UUID]User{}
	}
	r.users[user.ID] = user

	return nil
}

In this example, we have a new UserRepository interface and two implementations: DBUserRepository and MemoryUserRepository. As we can observe, MemoryUserRepository includes the Context argument, although it’s not actually needed. It’s there just to adhere to the interface, and that’s where the problem begins. We’ve adapted MemoryUserRepository to conform to the interface, even though this adaptation feels unnatural. Consequently, this approach allows us to switch data sources in our application, where one source is not a permanent storage solution.

The issue here is that the Repository pattern is intended to represent an interface to the underlying permanent data storage, such as a database. It should not double as a caching system, as in the case where we store Users in memory. Unnatural implementations like this one can have consequences not only in terms of semantics but also in the actual code. Such situations are more apparent during implementation and challenging to rectify, often requiring significant refactoring.

To illustrate this case, we can examine the famous example involving geometrical shapes. Interestingly, this example contradicts geometric principles.

Geometrical problem

type ConvexQuadrilateral interface {
	GetArea() int
}

type Rectangle interface {
	ConvexQuadrilateral
	SetA(a int)
	SetB(b int)
}

type Oblong struct {
	Rectangle
	a int
	b int
}

func (o *Oblong) SetA(a int) {
	o.a = a
}

func (o *Oblong) SetB(b int) {
	o.b = b
}

func (o Oblong) GetArea() int {
	return o.a * o.b
}

type Square struct {
	Rectangle
	a int
}

func (o *Square) SetA(a int) {
	o.a = a
}

func (o Square) GetArea() int {
	return o.a * o.a
}

func (o *Square) SetB(b int) {
	//
	// should it be o.a = b ?
	// or should it be empty?
	//
}

In the example above, we can see the implementation of geometrical shapes in Go. In geometry, we can establish subtyping relationships among convex quadrilaterals, rectangles, oblongs, and squares. When translating this concept into Go code for implementing area calculation logic, we may end up with something similar to what we see here.

At the top, we have an interface called ConvexQuadrilateral, which defines only one method, GetArea. As a subtype of ConvexQuadrilateral, we define an interface called Rectangle. This subtype includes two methods, SetA and SetB, as rectangles have two sides relevant to their area.

Next, we have the actual implementations. The first one is Oblong, which can have either a wider width or a wider height. In geometry, it refers to any rectangle that is not a square. Implementing the logic for this struct is straightforward.

The second subtype of Rectangle is Square. In geometry, a square is considered a subtype of a rectangle. However, if we follow this subtyping relationship in software development, we encounter an issue. A square has all four sides equal, making the SetB method obsolete. To adhere to the initial subtyping structure we chose, we end up with obsolete methods in our code. The same issue arises if we opt for a slightly different approach:

Another Geometrical problem

type ConvexQuadrilateral interface {
	GetArea() int
}

type EquilateralRectangle interface {
	ConvexQuadrilateral
	SetA(a int)
}

type Oblong struct {
	EquilateralRectangle
	a int
	b int
}

func (o *Oblong) SetA(a int) {
	o.a = a
}

func (o *Oblong) SetB(b int) {
	// where is this method defined?
	o.b = b
}

func (o Oblong) GetArea() int {
	return o.a * o.b
}

type Square struct {
	EquilateralRectangle
	a int
}

func (o *Square) SetA(a int) {
	o.a = a
}

func (o Square) GetArea() int {
	return o.a * o.a
}

In the example above, instead of using the Rectangle interface, we introduced the EquilateralRectangle interface. In geometry, this interface represents a rectangle with all four sides equal. In this case, by defining only the SetA method in our interface, we avoid introducing obsolete code in our implementation. However, this approach still breaks the Liskov Substitution Principle because we introduced an additional method, SetB, for the Oblong type, which is necessary to calculate the area, even though our interface implies otherwise.

Now that we’ve started grasping the concept of The Liskov Substitution Principle in Go, let’s summarize what can go wrong if we violate it:

  1. It provides a false shortcut for implementation.
  2. It can lead to obsolete code.
  3. It can disrupt the expected code execution.
  4. It can undermine the intended use case.
  5. It can result in an unmaintainable interface structure.

So, once again, let’s proceed with some refactoring.

How we do respect The Liskov Substitution

We can achieve subtyping in Go through interfaces by ensuring that each implementation adheres to the interface’s purpose and methods.

I won’t provide the corrected implementation for the first example we encountered, as the issue is quite obvious: the Update method should update the User, not delete it. Instead, let’s focus on resolving the problem with different implementations of the UserRepository interface:

Repositories and Caches

type UserRepository interface {
	Create(ctx context.Context, user User) (*User, error)
	Update(ctx context.Context, user User) error
}

type MySQLUserRepository struct {
	db *gorm.DB
}

type CassandraUserRepository struct {
	session *gocql.Session
}

type UserCache interface {
	Create(user User)
	Update(user User)
}

type MemoryUserCache struct {
	users map[uuid.UUID]User
}

In this example, we have divided the interface into two separate interfaces, each with distinct purposes and method signatures. We now have the UserRepository interface, which is dedicated to permanently storing user data in some storage. To fulfill this purpose, we have provided concrete implementations such as MySQLUserRepository and CassandraUserRepository.

On the other hand, we introduced the UserCache interface, which serves the specific function of temporarily caching user data. As a concrete implementation of UserCache, we can utilize MemoryUserCache. Now, let’s explore a more intricate scenario in the geometrical example:

Solving Geometrical problem

type ConvexQuadrilateral interface {
	GetArea() int
}

type EquilateralQuadrilateral interface {
	ConvexQuadrilateral
	SetA(a int)
}

type NonEquilateralQuadrilateral interface {
	ConvexQuadrilateral
	SetA(a int)
	SetB(b int)
}

type NonEquiangularQuadrilateral interface {
	ConvexQuadrilateral
	SetAngle(angle float64)
}

type Oblong struct {
	NonEquilateralQuadrilateral
	a int
	b int
}

type Square struct {
	EquilateralQuadrilateral
	a int
}

type Parallelogram struct {
	NonEquilateralQuadrilateral
	NonEquiangularQuadrilateral
	a     int
	b     int
	angle float64
}

type Rhombus struct {
	EquilateralQuadrilateral
	NonEquiangularQuadrilateral
	a     int
	angle float64
}

To support subtyping for geometrical shapes in Go, it’s crucial to consider all of their features to avoid broken or obsolete methods. In this case, we introduced three new interfaces: EquilateralQuadrilateral (representing a quadrilateral with all four equal sides), NonEquilateralQuadrilateral (representing a quadrilateral with two pairs of equal sides), and NonEquiangularQuadrilateral (representing a quadrilateral with two pairs of equal angles). Each of these interfaces provides additional methods necessary to supply the required data for area calculation.

Now, we can define a Square interface with only the SetA method, an Oblong interface with both SetA and SetB methods, and a Parallelogram interface with all these methods plus SetAngle. In this approach, we didn’t strictly adhere to subtyping but focused on including necessary features. With these fixed examples, we’ve restructured our code to consistently meet end-user expectations. This also eliminates obsolete methods without breaking any existing ones, resulting in stable code.

Conclusion

The Liskov Substitution Principle teaches us the correct way to apply subtyping. We should avoid forced polymorphism, even if it mimics real-world situations. The LSP represents the letter L in the word SOLID. While it is typically associated with inheritance and classes, which are not supported in Go, we can still apply this principle to achieve polymorphism and interfaces.

Useful Resources

comments powered by Disqus