The fourth principle of the SOLID principles, which is an acronym for principles explaining the things that must be kept in mind while developing robust, flexible and maintainable components of software, is the Interface Segregation Principle.
What is Interface Segregation Principle?
The principle states as follows –
Clients should not be forced to depend on methods which they don’t use
Which can be understood as follows –
Interfaces which are generated for clients to access must be designed in such a way that they should contain exactly and only those attributes and functionalities which are totally consumed by the client
Problem with Partial Interfaces
It is a general tendency while developing application components to stuff up a single class with all the application logic which is being used, irrespective of their relevance with the class it is stuffed into. It usually happens at the starting stages of applications when there’s not much complexity involved.
We tend to progress on top of these basic steps when building features further and more and more fatty classes are formed without our notice.
When we extract interfaces for these classes at a latter point for extension or modularity and share with other communicating components, we end up having so many methods and functionalities which may not be useful for the other components to use.
Components also end up implementing these methods unnecessarily causing further ambiguity. This is a clear violation of the Interface Segregation Principle.
The principle recommends proper designing of classes and interfaces with a strict enforcement of what is required in a class or an interface and what resides within must be only related.
The interfaces must be designed such that they are small and cohesive, and must serve a single responsibility. This is also a facet of the Single Responsibility; the first SOLID principle which has been discussed before
Problem of Partial Interfaces with an Example
For example, let’s consider we have an interface ILogic which is an interface that defines various areas and volumes. And it was developed without much of an idea and ended up in a fat interface containing a variety of methods for calculating Areas and Volumes of figures.
public interface ILogic
{
int calculateArea(int side);
int calculateArea(int length, int breadth);
int calculateVolume(int side);
int calculateVolume(int length, int breadth, int height);
}
Let’s say that a client class, say a Square has to be implemented using this interface. And a square is a two-dimensional shape and can contain only area which needs only a single side as a parameter.
But since the interface it needs to implement has many unnecessary methods, it ends up in a class with only a single wanted method and three other unwanted methods and hence unimplemented methods.
public class SqaureLogic : ILogic
{
public int calculateArea(int side)
{
return side * side;
}
public int calculateArea(int length, int breadth)
{
throw new NotImplementedException();
}
public int calculateVolume(int side)
{
throw new NotImplementedException();
}
public int calculateVolume(int length, int breadth, int height)
{
throw new NotImplementedException();
}
}
This is a clear violation of the Interface Segregation Principle which advocates for avoiding unwanted implementations.
This is also a violation of the Liskov Substitution Principle, which requires an implementation to be completely substitutable for a base type. Here the type SquareLogic is not at all substitutable for the interface base type ILogic which has so many unimplemented methods.
The solution in such scenarios is simple
- Break the large interfaces into smaller and cohesive interfaces with unique responsibilities.
- For backward compatibility, stitch up the larger interface from smaller ones if needed by making best use of multiple inheritance in such scenarios.
- When needed to integrate two or more smaller interfaces together for cross-platform functionalities, make use of Adapter pattern to develop cross compatible components.
And when designing such components and interfaces the below things must be kept in mind:
- The clients own and create their own interfaces instead of basing on the implementation components, which helps in clear segregation of responsibilities accordingly.
- Ensure all the interface types are declared in a namespace accessible to clients and implementations such that both can easily make use of them.
Interface Segregation with an Example
Keeping the above points in mind, we break the fat interface ILogic into multiple smaller types ISqaure, IRectangle, ICube and ICuboid which have a single responsibility for each. The interfaces are segregated as below:
namespace ReadersApi.Interfaces
{
public interface ISquare
{
int calculateArea(int side);
}
public interface IRectangle
{
int calculateArea(int length, int breadth);
}
public interface ICube
{
int calculateVolume(int side);
}
public interface ICuboid
{
int calculateVolume(int length, int breadth, int height);
}
public interface ILogic : ISquare, IRectangle, ICube, ICuboid
{
}
}
Observe that we have separated each functionality to a specific interface that best serves the purpose for it. And we have the ILogic still relevant for any legacy code support, by having the ILogic extend from all the individual interfaces by means of multiple inheritance.
And the implementation classes can easily make use of a interface that best matches its responsibility rather than the fat and irrelevant ones.
using ReadersApi.Interfaces;
namespace ReadersApi.Providers
{
public class SqaureLogic : ISquare
{
public int calculateArea(int side)
{
return side * side;
}
}
public class RectangleLogic : IRectangle
{
public int calculateArea(int length, int breadth)
{
return length * breadth;
}
}
}
public class Client
{
ISquare square;
public Client()
{
// SquareLogic is fully substitutable for ISquare
this.square = new SqaureLogic();
}
public void Invoke() {
Console.WriteLine(square.calculateArea(2));
}
}
Conclusion
The interfaces now are grouped under a common namespace ReadersApi.Interfaces
which is imported at the implementation end for usage. And the Square and Rectangle implementations no longer need to implement unnecessary logic for them, as the base types they implement are fully substituted.
In this way, we can have our components comply with both Single Responsibility and Liskov Substitution Principles, when they comply with the Interface Segregation Principle.
This helps in better code structuring and maintainable code components which can be scaled as the application grows.