Created: September 14, 2023

SOLID: Interface Segregation Principle in Golang

Dmitriy Neustroev

Dmitriy Neustroev

Software Engineer

Backend development
SOLID: Interface Segregation Principle in Golang

Acronymic SOLID principles were formulated by Robert C. Martin, also known as "Uncle Bob," one of the most influential software developers in the field.

In this article, we focus on the "I" in the SOLID principles and explain the idea of the Interface Segregation Principle (ISP) behind it.

But, first of all, you need to know about the "fat" interface problem. 

What's wrong with "fat" interfaces

As you know, in programming, an interface is a contract defining a set of methods without specific implementation. It describes the behavior that a type must provide.

The term "fat interfaces" refers to interfaces or classes with many methods, properties, or responsibilities. They bring design and maintenance challenges by promoting unnecessary coupling, complexity, and code bloat.

Main problems of fat interfaces:

To avoid such risks, we recommend using the ISP with simple interfaces.

Benefits of ISP in a nutshell

ISP states:

A client should never be forced to implement an interface that it doesn't use, or clients shouldn't be forced to depend on methods they do not use.

With the following, we can highlight the main benefits:

A small note: It's important to remember that when implementing business logic, you can't have many interfaces with a single method. It depends on the context of the project. Only the most common interfaces, which are usually uninvolved in business logic implementation, may have only one method.

Understanding the ISP in practice

Let's consider the use of this principle with an example. Suppose we must implement a scenario for searching bus routes between two bus stops. The trip is described by the Trip structure.

// model
package model

// imports ...

type Trip struct {
  From string
  To   string
  Date time.Time
  Bus  string
}

An example of a violation of the ISP

// usecase/trip/search
package search

// imports ...

type UseCase struct {
  tripRepo repository.TripRepository
// ...
}

func NewUseCase(tripRepo repository.TripRepository)(*UseCase, error) {
// ...
}
func (uc *UseCase) SearchTrips(
  ctx context.Context, from, to string, date time.Time,
)([]model.Trip, error) {
// ...
  trips, err := uc.tripRepo.FindTrips(ctx, from, to, date)
// ...
}

The trip search scenario contains the method func (uc *UseCase) SearchTrips(ctx context.Context, from, to string, date time.Time) ([]model.Trip, error), which searches for trips from stop to stop on a specific date. It returns a list of trips and nil as an error if successful, or nil as the list of trips and an error. The scenario uses the FindTrips method from the trips repository. The repository dependency is injected through an interface stored in the repository package.

// adapter/repository
package repository

// imports ...

type TripRepository interface {
  SaveTrip(
    ctx context.Context, t *model.Trip,
  ) (*model.Trip, error)
  GetTrip(
    ctx context.Context, id int,
  ) (*model.Trip, error)
  FindTrips(
    ctx context.Context, stopFrom, stopTo string, date time.Time,
  ) ([]model.Trip, error)
}

Also, in this package, there is an implementation of the repository using PostgreSQL as the data storage.

type TripPostgresRepository struct {
// ...
}

// some implementation here

In this example, our UseCase scenario depends on the TripRepository interface. However, the scenario only uses one method from the repository, FindTrips, and does not utilize the SaveTrip and GetTrip methods. This violates the ISP, specifically its second part, "Clients shouldn't be forced to depend on methods they do not use."

Drawbacks of violating ISP in this context include:

type TripRepositoryMock struct {
  // ...
}

func (m *TripRepositoryMock) SaveTrip(
  ctx context.Context, t *model.Trip,
) (*model.Trip, error) {
  return nil, errors.New("not implemented")
}

func (m *TripRepositoryMock) GetTrip(
  ctx context.Context, id int,
) (*model.Trip, error) {
  return nil, errors.New("not implemented")
}

func (m *TripRepositoryMock) FindTrips(
  ctx context.Context, stopFrom, stopTo string, date time.Time,
) ([]model.Trip, error) {
  // some implementation here
}

Here is an example with the ISP:

// usecase/trip/search
package search

// imports ...

type TripsFinder interface {
  FindTrips(
    ctx context.Context, stopFrom, stopTo string, date time.Time,
  ) ([]model.Trip, error)
}


type UseCase struct {
  tripFinder TripsFinder
  // ...
}

func NewUseCase(tripsFinder TripsFinder) (*UseCase, error) {
  // ...
}

func (uc *UseCase) SearchTrips(
  ctx context.Context, from, to string, date time.Time,
) ([]model.Trip, error) {
  // ...
  trips, err := uc.tripFinder.FindTrips(ctx, from, to, date)
  // ...
}

Changes:

// usecase/trip/search/mock
package mock

// imports ...

type TripsFinderMock struct {
  // ...
}

func (m *TripsFinderMock) FindTrips(
  ctx context.Context, stopFrom, stopTo string, date time.Time,
) ([]model.Trip, error) {
  // some implementation
}

Changes:

// adapter/repository
package repository

// imports ...

type TripPostgresRepository struct {
  // ...
}

func (r *TripPostgresRepository) FindTrips(
  ctx context.Context, stopFrom, stopTo string, date time.Time,
) ([]model.Trip, error) {
  // some implementation
}
// ...

Benefits of applying ISP in this example:


💡 Code refactoring checklist: Get your structured guide to enhance code quality during optimization processes while preserving external functionality.


To sum up, by applying the ISP, we eliminated the burden of a "fat interface" (Repository) and transitioned to a narrowly specialized interface (TripsFinder). This segmentation allows clients like UseCase and TripsFinderMock to depend only on methods pertinent to their specific tasks, reducing code complexity and making the codebase more maintainable and testable.

Best practices for implementing ISP

  1. Declare an interface where you need to inject a dependency. When incorporating a dependency, use that interface.

  2. Include in the interface only the methods needed by a specific client (struct).

  3. One interface should have one responsibility. Remember the Single Responsibility principle.

  4. When designing an interface, focus on the behavior you want to achieve when using it, rather than on data and implementation details.

  5. If the interface you want to use contains methods not used by your client code, declare a new interface without these methods and incorporate it.

  6. Utilize interface embedding to build a more complex interface based on simpler ones, instead of copying their methods into your own interface.

  7. The constructor of a structure should return a pointer to your specific structure, not an interface. Interfaces should be stored and declared in the client's package, not in the package with a concrete implementation.

To wrap up

The ISP is a guiding light that helps create modular and maintainable software systems. By dividing interfaces into smaller, client-focused units, we enable flexibility, scalability, and code that is resilient to change. Code examples like those presented in this article showcase the power of ISP in action, emphasizing its practical value in real-world software development.


FAQ

What is the Interface Segregation Principle (ISP)?

ISP is a SOLID design principle in programming. It states that clients should not be forced to depend on methods they do not use. In essence, interfaces should be tailored to clients' specific needs to ensure clean and efficient code.

How does ISP differ from other SOLID principles?

While all SOLID principles aim to enhance software design, ISP focuses on the structure of interfaces. It emphasizes creating smaller, more specialized interfaces to prevent unnecessary method implementation in clients. In contrast, other SOLID principles address single responsibilities, open-closed behavior, and dependency inversion.

Are there cases where ISP might not be applicable or beneficial?

ISP might be less applicable in simple projects with minimal interface complexity. However, as projects scale and evolve, ISP becomes more beneficial to maintainability and flexibility.

Does ISP only apply to object-oriented programming?

While the SOLID principles have roots in object-oriented programming, the core ideas behind ISP — creating specific, client-focused interfaces — can also apply to others. For example, functional programming also benefits from clean and modular interface design.