Skip to main content
  1. Articles/

Practical SOLID in Golang: Liskov Substitution Principle

·1880 words·9 mins· loading · loading ·
Marko Milojevic
Author
Marko Milojevic
Software engineer and architect. Golang and LLM enthusiast. Awful chess player, gym rat, harmonica newbie and cat lover.
SOLID principles in Golang - This article is part of a series.
Part 3: This Article

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
#

SOLID principles in Golang - This article is part of a series.
Part 3: This Article

Related

Practical SOLID in Golang: Open/Closed Principle

·1369 words·7 mins· loading · loading
Many different approaches and principles can lead to long-term improvements in our code. Some of them are well-known in the software development community, while others remain somewhat under the radar. In my opinion, this is the case with The Open/Closed Principle, represented by the letter O in the word SOLID. In my experience, only those genuinely interested in SOLID principles tend to understand what this principle means. We may have applied this principle without even realizing it in some instances, such as when working with the Strategy pattern. However, the Strategy pattern is just one application of the Open/Closed Principle. In this article, we will delve into the full purpose of this principle, with all examples provided in Go. When we do not respect the Open/Closed Principle # You should be able to extend the behavior of a system without having to modify that system. The requirement for the Open/Closed Principle, as seen above, was provided by Uncle Bob in his blog. I prefer this way of defining The Open/Closed Principle because it highlights its full brilliance. At first glance, it may seem like an absurd requirement. Seriously, how can we extend something without modifying it? I mean, is it possible to change something without changing it? By examining the code example below, we can see what it means for certain structures not to adhere to this principle and the potential consequences. The bad Authentication Service type AuthenticationService struct { // // some fields // } func (s *AuthenticationService) Authenticate(ctx *gin.Context) (*User, error) { switch ctx.GetString("authType") { case "bearer": return c.authenticateWithBearerToken(ctx.Request.Header) case "basic": return c.authenticateWithBasicAuth(ctx.Request.Header) case "applicationKey": return c.authenticateWithApplicationKey(ctx.Query("applicationKey")) } return nil, errors.New("unrecognized authentication type") } func (s *AuthenticationService) authenticateWithApplicationKey(key string) (*User, error) { // // authenticate User from Application Key // } func (s *AuthenticationService) authenticateWithBasicAuth(h http.Header) (*User, error) { // // authenticate User from Basic Auth // } func (s *AuthenticationService) authenticateWithBearerToken(h http.Header) (*User, error) { // // validate JWT token from the request header // } The example presents a single struct, AuthenticationService. Its purpose is to authenticate a User from the web application’s Context, supported by the Gin package. Here, we have the main method, Authenticate, which checks for specific authentication type associated with the data within the Context. How User is retrieved from the Context may vary based on whether the User uses a bearer JWT token, basic authentication, or an application key.

Practical SOLID in Golang: Single Responsibility Principle

·2013 words·10 mins· loading · loading
There aren’t too many opportunities for a breakthrough in software development. They usually arise from either rewiring our logic after initial misunderstandings or filling in gaps in our knowledge. I appreciate that feeling of deeper understanding. It can happen during a coding session, while reading a book or an online article, or even while sitting on a bus. An internal voice follows, saying, “Ah, yes, that’s how it works.” Suddenly, all past mistakes seem to have a logical reason, and future requirements take shape. I experienced such a breakthrough with the SOLID principles, which were first introduced in a document by Uncle Bob and later expounded upon in his book, “Clean Architecture.” In this article, I intend to embark on a journey through all the SOLID principles, providing examples in Go. The first principle on the list, representing the letter ‘S’ in SOLID, is the Single Responsibility Principle. When we do not respect Single Responsibility # The Single Responsibility Principle (SRP) asserts that each software module should serve a single, specific purpose that could lead to change. The sentence above comes directly from Uncle Bob himself. Initially, its application was linked to modules and the practice of segregating responsibilities based on the organization’s daily tasks. Nowadays, SRP has a broader scope, influencing various aspects of software development. We can apply its principles to classes, functions, modules, and naturally, in Go, even to structs. Some Frankenstein of EmailService type EmailService struct { db *gorm.DB smtpHost string smtpPassword string smtpPort int } func NewEmailService(db *gorm.DB, smtpHost string, smtpPassword string, smtpPort int) *EmailService { return &EmailService{ db: db, smtpHost: smtpHost, smtpPassword: smtpPassword, smtpPort: smtpPort, } } func (s *EmailService) Send(from string, to string, subject string, message string) error { email := EmailGorm{ From: from, To: to, Subject: subject, Message: message, } err := s.db.Create(&email).Error if err != nil { log.Println(err) return err } auth := smtp.PlainAuth("", from, s.smtpPassword, s.smtpHost) server := fmt.Sprintf("%s:%d", s.smtpHost, s.smtpPort) err = smtp.SendMail(server, auth, from, []string{to}, []byte(message)) if err != nil { log.Println(err) return err } return nil } Let’s analyze the code block above. In this code, we have a struct called EmailService, which contains only one method, Send. This service is intended for sending emails. Although it may seem okay at first glance, upon closer inspection, we realize that this code violates the Single Responsibility Principle (SRP) in several ways.

Practical DDD in Golang: Specification

·1596 words·8 mins· loading · loading
There are not many code structures that bring me joy whenever I need to write them. The first time I implemented such code was with a lightweight ORM in Go, back when we didn’t have one. However, I used ORM for many years, and at some point, when you rely on ORM, using QueryBuilder becomes inevitable. Here, you may notice terms like “predicates”, and that’s where we can find the Specification pattern. It’s hard to find any pattern we use as Specification, yet we do not hear its name. I think the only thing harder is to write an application without using this pattern. The Specification has many applications. We can use it for querying, creation, or validation. We may provide a unique code that can do all this work or provide different implementations for different use cases. For Validation # The first use case for the Specification pattern is validation. Typically, we validate data in forms, but this is at the presentation level. Sometimes, we perform validation during creation, such as for Value Objects. In the context of the domain layer, we can use Specifications to validate the states of Entities and filter them from a collection. So, validation at the domain layer has a broader meaning than for user inputs. Base Product Specification type Product struct { ID uuid.UUID Material MaterialType IsDeliverable bool Quantity int } type ProductSpecification interface { IsValid(product Product) bool } A simple Product Specification type HasAtLeast struct { pieces int } func NewHasAtLeast(pieces int) ProductSpecification { return HasAtLeast{ pieces: pieces, } } func (h HasAtLeast) IsValid(product Product) bool { return product.Quantity >= h.pieces } In the example above, there is an interface called ProductSpecification. It defines only one method, IsValid, which expects instances of Product and returns a boolean value as a result if the Product passes validation rules. A simple implementation of this interface is HasAtLeast, which verifies the minimum quantity of the Product. Function as a Product Specification type FunctionSpecification func(product Product) bool func (fs FunctionSpecification) IsValid(product Product) bool { return fs(product) } func IsPlastic(product Product) bool { return product.Material == Plastic } func IsDeliverable(product Product) bool { return product.IsDeliverable } More interesting validators are two functions, IsPlastic and IsDeliverable. We can wrap those functions with a specific type, FunctionSpecification. This type embeds a function with the same signature as the two mentioned. Besides that, it provides methods that respect the ProductSpecification interface. This example is a nice feature of Go, where we can define a function as a type and attach a method to it so that it can implicitly implement some interface. In this case, it exposes the method IsValid, which executes the embedded function.