Skip to main content
  1. Articles/

Practical SOLID in Golang: Interface Segregation Principle

·1722 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 4: This Article

When beginners embark on their programming journey, the initial focus is typically on algorithms and adapting to a new way of thinking. After some time, they delve into Object-Oriented Programming (OOP). If this transition is delayed, it can be challenging to shift from a functional programming mindset. However, eventually, they embrace the use of objects and incorporate them into their code where necessary, sometimes even where they’re not needed. As they learn about abstractions and strive to make their code more reusable, they may overgeneralize, resulting in abstractions applied everywhere, which can hinder future development. At some point, they come to realize the importance of setting boundaries for excessive generalization. Fortunately, The Interface Segregation Principle has already provided a guideline for this, representing the “I” in the word SOLID.

When we do not respect The Interface Segregation
#

Maintain small interfaces to prevent users from relying on unnecessary features.

Uncle Bob introduced this principle, and you can find more details about it on his blog. This principle clearly states its requirement, perhaps better than any other SOLID principle. Its straightforward advice to keep interfaces as small as possible should not be interpreted as merely advocating one-method interfaces. Instead, we should consider the cohesion of features that an interface encompasses.

Let’s analyze the code below:

User interface

type User interface {
	AddToShoppingCart(product Product)
	IsLoggedIn() bool
	Pay(money Money) error
	HasPremium() bool
	HasDiscountFor(product Product) bool
	//
	// some additional methods
	//
}

Let’s assume we want to create an application for shopping. One approach is to define an interface User, as demonstrated in the code example. This interface includes various features that a user can have. On our platform, a User can add a Product to the ShoppingCart, make a purchase, and receive discounts on specific Products. However, the challenge is that only specific types of Users can perform all of these actions.

Guest struct

type Guest struct {
	cart ShoppingCart
	//
	// some additional fields
	//
}

func (g *Guest) AddToShoppingCart(product Product) {
	g.cart.Add(product)
}

func (g *Guest) IsLoggedIn() bool {
	return false
}

func (g *Guest) Pay(Money) error {
	return errors.New("user is not logged in")
}

func (g *Guest) HasPremium() bool {
	return false
}

func (g *Guest) HasDiscountFor(Product) bool {
	return false
}

We have implemented this interface with three structs. The first one is the Guest struct, representing a user who is not logged in but can still add a Product to the ShoppingCart. The second implementation is the NormalCustomer, which can do everything a Guest can, plus make a purchase. The third implementation is the PremiumCustomer, which can use all features of our system.

NormalCustomer struct

type NormalCustomer struct {
	cart   ShoppingCart
	wallet Wallet
	//
	// some additional fields
	//
}

func (c *NormalCustomer) AddToShoppingCart(product Product) {
	c.cart.Add(product)
}

func (c *NormalCustomer) IsLoggedIn() bool {
	return true
}

func (c *NormalCustomer) Pay(money Money) error {
	return c.wallet.Deduct(money)
}

func (c *NormalCustomer) HasPremium() bool {
	return false
}

func (c *NormalCustomer) HasDiscountFor(Product) bool {
	return false
}

PremiumCustomer struct

type PremiumCustomer struct {
	cart     ShoppingCart
	wallet   Wallet
	policies []DiscountPolicy
	//
	// some additional fields
	//
}

func (c *PremiumCustomer) AddToShoppingCart(product Product) {
	c.cart.Add(product)
}

func (c *PremiumCustomer) IsLoggedIn() bool {
	return true
}

func (c *PremiumCustomer) Pay(money Money) error {
	return c.wallet.Deduct(money)
}

func (c *PremiumCustomer) HasPremium() bool {
	return true
}

func (c *PremiumCustomer) HasDiscountFor(product Product) bool {
	for _, p := range c.policies {
		if p.IsApplicableFor(c, product) {
			return true
		}
	}
	
	return false
}

Now, take a closer look at all three structs. Only the PremiumCustomer requires all three methods. Perhaps we could assign all of them to the NormalCustomer, but definitely, we hardly need more than two methods for the Guest. Methods like HasPremium and HasDiscountFor don’t make sense for a Guest. If this struct represents a User who is not logged in, why would we even consider implementing methods for discounts? In such cases, we might even call the panic method with the error message “method is not implemented” — that would be more honest in this code. In a typical scenario, we shouldn’t even call the HasPremium method from a Guest.

UserService struct

type UserService struct {
	//
	// some fields
	//
}

func (u *UserService) Checkout(ctx context.Context, user User, product Product) error {
	if !user.IsLoggedIn() {
		return errors.New("user is not logged in")	
	}
	
	var money Money
	//
	// some calculation
	//
	if user.HasDiscountFor(product) {
		//
		// apply discount
		//
	}
	
	return user.Pay(money)
}

All of this complexity was introduced to add generalization inside the UserService to handle all types of Users in the same place, using the same code. However, as a result, we now have:

  1. Many structs with unused methods.
  2. Methods that need to be marked to prevent their use.
  3. Additional code for unit testing.
  4. Unnatural polymorphism.

So, let’s refactor this situation to improve it.

How we do respect The Interface Segregation
#

Build interfaces around the minimal cohesive group of features.

We don’t need to overcomplicate things; all we have to do is define a minimal interface that provides a complete set of features. Let’s take a look at the following code:

User interfaces

type User interface {
	AddToShoppingCart(product Product)
	//
	// some additional methods
	//
}

type LoggedInUser interface {
	User
	Pay(money Money) error
	//
	// some additional methods
	//
}

type PremiumUser interface {
	LoggedInUser
	HasDiscountFor(product Product) bool
	//
	// some additional methods
	//
}

Now, instead of one interface, we have three: PremiumUser embeds LoggedInUser, which embeds User. Additionally, each of them introduces one method. The User interface now represents only customers who are still not authenticated on our platform. For such types, we know they can use features of the ShoppingCart. The new LoggedInUser interface represents all our authenticated customers, and the PremiumUser interface represents all authenticated customers with a paid premium account.

Concrete User implementations

type Guest struct {
	cart ShoppingCart
	//
	// some additional fields
	//
}

func (g *Guest) AddToShoppingCart(product Product) {
	g.cart.Add(product)
}

type NormalCustomer struct {
	cart   ShoppingCart
	wallet Wallet
	//
	// some additional fields
	//
}

func (c *NormalCustomer) AddToShoppingCart(product Product) {
	c.cart.Add(product)
}

func (c *NormalCustomer) Pay(money Money) error {
	return c.wallet.Deduct(money)
}

type PremiumCustomer struct {
	cart     ShoppingCart
	wallet   Wallet
	policies []DiscountPolicy
	//
	// some additional fields
	//
}

func (c *PremiumCustomer) AddToShoppingCart(product Product) {
	c.cart.Add(product)
}

func (c *PremiumCustomer) Pay(money Money) error {
	return c.wallet.Deduct(money)
}

func (c *PremiumCustomer) HasDiscountFor(product Product) bool {
	for _, p := range c.policies {
		if p.IsApplicableFor(c, product) {
			return true
		}
	}

	return false
}

Notice this: we indeed added two more interfaces, but we removed two methods: IsLoggedIn and HasPremium. Those methods are not part of our interface signature. But how can we work without them?

UserService struct

type UserService struct {
	//
	// some fields
	//
}

func (u *UserService) Checkout(ctx context.Context, user User, product Product) error {
	loggedIn, ok := user.(LoggedInUser)
	if !ok {
		return errors.New("user is not logged in")
	}

	var money Money
	//
	// some calculation
	//
	if premium, ok := loggedIn.(PremiumUser); ok && premium.HasDiscountFor(product)  {
		//
		// apply discount
		//
	}

	return loggedIn.Pay(money)
}

As you can see in the UserService, instead of using methods with boolean results, we just clarify the subtype of the User interface. If User implements LoggedInUser, we know that we are dealing with an authenticated customer. Also, if User implements PremiumUser, we know that we are dealing with a customer with a premium account. So, by casting, we can already check for some business rules. Besides those two methods, all structs from before are now more lightweight. Instead of each of them having five methods, where many of them are not used at all, now they only have methods they really need.

Some more examples
#

Although it is always good to create small and flexible interfaces, we should introduce them with their purpose in mind. Adding small interfaces to simplify them but still implementing them together in the same struct does not make too much sense.

Let us examine the example below:

Too much splitting

type UserWithFirstName interface {
	FirstName() string
}

type UserWithLastName interface {
	LastName() string
}

type UserWithFullName interface {
	FullName() string
}

type UserWithDiscount interface {
	HasDiscountFor(product Product) bool
}

Optimal splitting

type UserWithName interface {
	FirstName() string
  	LastName() string
  	FullName() string
}

type UserWithDiscount interface {
    UserWithName
	HasDiscountFor(product Product) bool
}

In this case, we’ve split the interface too finely. While one-method interfaces can be useful in some situations, it doesn’t make sense here. If a customer is registered on our platform, they will need to provide both their first and last name for billing purposes. So, our User interface should include both the FirstName and LastName methods, and naturally, FullName as well.

Splitting these three methods into three separate interfaces doesn’t make sense, as these methods are closely related and always go together. This isn’t the right example for one-method interfaces.

But what would be a good example?

Example from IO package

package io

type Reader interface {
	Read(p []byte) (n int, err error)
}

type Writer interface {
	Write(p []byte) (n int, err error)
}

type Closer interface {
	Close() error
}

type Seeker interface {
	Seek(offset int64, whence int) (int64, error)
}

type WriteCloser interface {
	Writer
	Closer
}

type ReadWriteCloser interface {
	Reader
	Writer
	Closer
}

//.... and so on

The perfect example in Go is the IO package. It provides many codes and interfaces for handling I/O operations, and probably all Go developers have used this package at least once. It provides interfaces such as Reader, Writer, Closer, and Seeker. Each of them defines only one method: Read, Write, Close, and Seek, respectively. We use all of these interfaces to read, write, seek within a slice of bytes, and close a particular source. To have more flexibility with different sources, all functionalities are placed in these interfaces. Later, they can be used to build more complex interfaces, like WriteCloser, ReadWriteCloser, and so on.

Conclusion
#

The Interface Segregation Principle is the fourth SOLID principle, represented by the letter “I” in the word SOLID. It teaches us to keep our interfaces as small as possible. When we need to accommodate various types, we should shield them with distinct interfaces. However, we should also refrain from creating excessively small interfaces and ensure they offer complete functionality.

Useful Resources
#

SOLID principles in Golang - This article is part of a series.
Part 4: 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 SOLID in Golang: Liskov Substitution Principle

·1880 words·9 mins· loading · loading
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.