Practical SOLID in Golang: Interface Segregation Principle
Published: 2022-05-11 05:03:58

Whenever someone starts with first programming lessons, the procedure is always similar: for the first couple of weeks or even months, it is all about algorithms and rewiring the brain to accept that way of thinking.

At some point, we start with an introduction to Object-Oriented Programming. If teachers wait too much for that switch, it may be difficult to cut off the connection to functional programming.

But, at some point, we accept to use objects. And we start using them where is needed, and of course, where is not. Then we start learning about abstractions and how to make our code more and more reusable.

And then, we may start using abstractions in the wrong way, adding them everywhere for everything. To make our code more general, we make generalizations that slow down our future development.

Sooner or later, we start realizing that we should draw a line for so many generalizations. Luckily, somebody already did it — by introducing The Interface Segregation Principle, which stands for letter I in the word SOLID.

**When we do not respect The Interface Segregation**

> Keep interfaces small so that users don’t end up depending on things they don’t need.

Its simple statement to keep interfaces as small as possible we should not understand as just one-method-interface, but to look more in a context of the features’ cohesion that interface holds.

Let us examine the code below:
`type User interface {
	AddToShoppingCart(product Product)
	IsLoggedIn() bool
	Pay(money Money) error
	HasPremium() bool
	HasDiscountFor(product Product) bool
	//
	// some additional methods
	//
}

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
}

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
}

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
}

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)
}`

Let us assume we want to deliver an application for shopping. One of the approaches is to define an interface User, as we did in the code example. This interface holds many features a user can have.

A User on our platform can add a Product to the ShoppingCart. They can buy it. They can get a discount on a particular Product. The only problem is that only a specific User can do all of that.

The actual implementations for this interface are three structs. The first one is the Guest struct. It should be a User who is not logged in on our system, but at least they can add a Product to the ShoppingCart.

The second implementation is the NormalCustomer. It can do whatever Guest can, plus to buy a Product. The third implementation is thePremiumCustomer, which can use all features of our system.

Now, look at all of those three structs. Only PremiumCustomer requires all three methods. Maybe we can assign all of them to NormalCustomer, but definitely, we hardly need more than two for Guest.

Methods HasPremium and HasDiscountFor do not have any sense for Guest. If that struct represents the User who is not logged in, why would we even consider implementing methods for discounts?

Here we may even call the panic method, with the error “method is not implemented” — that would be more honest in this code. In the usual scenario, we should not even call the method HasPremium from Guest.

And we did all of this to add generalization inside UserService to handle all types of Users in the same place, with the same code. But, because of that, we need to implement a bunch of unused methods.

So, to have better generalization, we got:

1. Many structs with unused methods.
2. Methods that we need somehow to mark so that others do not use them.
3. Much additional code for unit testing.
4. Unnatural polymorphism.

So, let us refactor this mess.

**How we do respect The Interface Segregation**

> Build interfaces around the minimal cohesive group of features.

We do not need to invent some space science here. The only needed is to define a minimal interface that delivers a complete set of features. Let us check the code below:

`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
	//
}

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
}

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)
}`

Now, instead of one, we have three interfaces. PremiumUser embeds LoggedInUser, which embeds User. In addition, each of them introduces one method.

The User now represents only customers who are still not authenticated on our platform. For such type, we know they can use features of ShoppingCart.

The new LoggedInUser interface represents all our authenticated customers, and thePremiumUser interface represents all authenticated customers with a paid premium account.

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

As you see in the UserService, instead of using methods with the boolean result, we just clarify the subtype of the User interface. If User implements LoggedInUser, we know that we talk about the authenticated customer.

Also, if User implements PremiumUser, we know that we talk about the customer with a premium account. So, by casting, we 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 just have methods they really need.

**Summary**
The Interface Segregation Principle is the fourth SOLID principle, and it stands for the letter I in the word SOLID. It teaches us to make our interfaces as tiny as possible.

Whenever we want to cover more types, we should protect them with different interfaces. We should avoid making our interfaces too small but deliver complete functionality.