Introduction
This is the third part of the SOLID series where I will briefly discuss the Liskov Substitution Principle.
This principle emphasizes semantic design over practical implementation, ensuring interchangeability of subclasses or interfaces. In Go, where inheritance is replaced by struct composition, this principle still applies, particularly in interface design. By abstracting parameters, interfaces become more flexible for future implementations. For instance, in a video platform scenario, an interface for updating video progression could be designed to accommodate different storage systems like Postgres or Redis. By omitting specific database parameters, implementations can be swapped without altering program correctness, leaving the decision of storage location to the implementation.
Definition
Let’s start with the definition:
Subtype Requirement: Let f(x) be a property provable about objects x of type T. Then f(y) should be true for objects y of type S where S is a subtype of T.
Liskov's notion of a behavioral subtype defines a notion of substitutability for objects; that is, 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 (e.g. correctness).
Core
This principle is perhaps the most semantically rather than practically oriented principle in the series. The principle aims to ensure that your superclasses or interfaces are arranged in such a way that subtypes of the classes/interfaces are interchangeable with each other.
Suppose you have a class A and class B. Class B inherits from class A. According to the principle, class B should be substitutable for class A without compromising the correctness of the program. If you were to override a method in B from A with a slightly different signature, you would violate the principle.
In the case of Golang, there are no classes and inheritance, but rather struct composition, so it's somewhat easier because in Golang, through the composition process, you don't actually inherit a type. So, a struct B that embeds struct A is not a subtype of A. This already makes it impossible to incorrectly override methods. It also prevents you from passing struct B as A since Golang does not accept that. In fact, Golang already protects us from many potential errors. However, Golang does have interfaces, and although an interface requires a signature, you can still incorporate this principle when designing an interface.
Interface design
The principle aims to encourage interchangeability. You want to assess the potential impact of future implementations. Consider, for example, choosing (or omitting) parameters of the methods. Omitting parameters is generally easier since a struct must comply with it and is not dependent on certain types. All logic is completely abstracted. However, it often happens that you need at least one parameter. So, you want to establish parameters that are as abstract as possible and not strongly tied to an existing implementation.
Suppose you are working on a video platform where users can watch videos. In order to start videos at the right time you need to know how far the user progressed in a video. Suppose you’d write an interface for this:
type User interface {
UpdateVideoProgression(database *Postgres, id string) bool
}
In the above case, we could easily write a postgres implementation. But what if we also want to store user information in Redis? In that case, the postgres database parameter is not useful, but omitting it is also not an option because then it no longer complies with the interface. In this case, it's better to omit it:
type User interface {
UpdateVideoProgression(id string) bool
}
Now, it doesn't matter where or how it's stored, and you can interchange different implementations without changing the correctness of the program. It is up to the implementation to decide where to store the video progression.