Practical SOLID in Golang: Liskov Substitution Principle
- 9 minutes read - 1876 wordsWe 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:
- It provides a false shortcut for implementation.
- It can lead to obsolete code.
- It can disrupt the expected code execution.
- It can undermine the intended use case.
- 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.