Practical SOLID in Golang: Interface Segregation Principle

- 9 minutes read - 1717 words

We continue our journey through the SOLID principles by discussing the one that has a profound impact on code design: The Interface Segregation Principle.

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

comments powered by Disqus