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:
- The more methods an interface has, the higher the risk of losing a clear scope of responsibility. Abstraction can become unclear.
- When used as dependencies, fat interfaces can be redundant because clients might only use a subset of the methods, rendering the rest unused.
- They are harder to test, maintain, and support.
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:
- They describe specific behavior within a clear scope of responsibility. With an interface containing a single method, it's easier to understand its scope of responsibility than an interface with 5-10 methods* (hello to the "god object" anti-pattern).
- They are easier to reuse since multiple objects from different contexts can implement the same behavior. For example, the behavior of the io.Reader interface is implemented by files, buffers, network connections, and more.
- They can be used as building blocks for more complex interfaces (for instance, the io.ReadWriter interface is constructed based on the io.Reader and io.Writer interfaces).
- They are easier to test, maintain, and support.
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.
An example of a violation of the ISP
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.
Also, in this package, there is an implementation of the repository using PostgreSQL as the data storage.
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:
- Code misunderstanding: When another developer reads the code of our scenario, they might assume that it depends on all the repository methods, even though it's not the case.
- Cascade changes: When adding methods to TripRepository or modifying the SaveTrip or GetTrip methods, the compiler will regenerate machine code for the use case package because it imports the repository package. This leads to cascading changes in the use case package every time there's a change in the repository package, even if the FindTrips method hasn't been changed.
- Unit testing complexity: To write unit tests for the scenario, you would need to create a mock for TripRepository and implement the SaveTrip and GetTrip methods, even though they are not used in the scenario. This violates the first part of ISP, "A client should never be forced to implement an interface that it doesn't use." In this case, we force the client (TripRepositoryMock) to implement methods (
SaveTrip
andGetTrip
) that are not used.
Here is an example with the ISP:
Changes:
- The scenario depends only on the FindTrips method that it uses.
- The TripsFinder interface is declared where the dependency is injected, i.e., in the use case package. The use case package does not import the repository package.
Changes:
- The mock is kept in a separate package within the use case package, alongside the place where it will be used.
- The mock does not import the repository package.
Benefits of applying ISP in this example:
- Improved code readability: When another developer reads the code of our scenario, they will clearly understand that the scenario depends only on the trip search method, enhancing code readability.
- Reduced code coupling: You can reduce code coupling by breaking down the methods into separate interfaces and ensuring that the use case package does not import the repository package. This means that when you add or modify methods in the TripRepository, the use case package won't need to be recompiled, preventing cascading changes.
- Cleaner Code: The mock object contains only the necessary method for testing, eliminating any unnecessary code. This leads to cleaner and more focused unit tests.
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
Declare an interface where you need to inject a dependency. When incorporating a dependency, use that interface.
Include in the interface only the methods needed by a specific client (struct).
One interface should have one responsibility. Remember the Single Responsibility principle.
When designing an interface, focus on the behavior you want to achieve when using it, rather than on data and implementation details.
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.
Utilize interface embedding to build a more complex interface based on simpler ones, instead of copying their methods into your own interface.
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.