Introduction
Do you know that 50% of software development time is spent on maintenance? As a result, experts dedicated their time to set principles we can apply to minimize maintenance time and refine the software design process. SOLID principles are among these principles.
SOLID principles are a set of principles set by Robert C. Martin (Uncle Bob). The main goal of these principles is to design software that is easy to maintain, test, understand, and extend.
These principles are:
- Single Responsibility Principle
- Open-Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion
After introducing the Liskov Substitution Principle in the previous article, in this article, we will discuss the Interface Segregation Principle (ISP) the fourth principle in SOLID.
By the end of this article, we should be familiar with the ISP definition, its violation, the relation between it and the other principles, to what extent we should apply it, how can we deal with the legacy code, and more.
Definition of the Interface Segregation Principle
Have you ever struggled to implement an interface containing methods you don’t actually need? I think we are all that person. OK, how do you deal with such a case? I think the majority of us would leave it blank or throw an unsupported exception, wouldn’t we? Don’t you believe that this is a sign of a bad design?
The ISP comes to the rescue. Robert C. Martin has defined the ISP as:
Clients should not be forced to depend upon interfaces that they do not use.
Here, I think you might have some valid questions like:
- What is the Client, is it the client of the Client-Server architecture? Don’t confuse this Client and any client definition you know. Simply, it is the code that’s interacting with the interface. It is the calling code. Take a look at the below example:
class User {}
interface IRegister {
register(): User;
hashPassword(): string;
}
class PasswordRegister implements IRegister {
register(): User {
return;
}
hashPassword(): string {
return;
}
}
class SocialRegister implements IRegister {
register(): User {
return;
}
hashPassword(): string {
throw new Error('UNSUPPORTED_OPERATION');
}
}
As you see, here the clients are SocialLogin
and PasswordLogin
classes. These classes are the code that interacts with the interface ILogin
.
In another case, if you have a third-party library that provides an interface that you have to implement. In such a case, the Client is your code, not the library.
- Is ISP specific only to Interfaces? Definitely, NO. Actually, it could be a usual interface or an abstract class. For the sake of simplicity, I’m going to use interfaces only in this article.
Before asking the next question, I know you have others in mind, don’t worry I’ll dig into them later.
At least, after answering these questions I think it is clear to you what is the ISP goal. Yes, as you might think, the ISP states that any client code shouldn’t be forced to depend on any interface methods they don’t use. That means you should prefer a small, cohesive interface to large, fat ones which in turn, ensures single responsible, and highly cohesive software components.
Take a close look again at the previous example, don’t you feel that it has a problem, did you know it? what do you think of the hashPassword
method in the SocialRegister
class, is it relevant? That’s it, your SocialRegister
class (the client) is forced to depend on the hashPassword
method it doesn’t need, that’s the problem.
This problem is considered a violation of the Interface Segregation Principle, and if you knew about the Liskov Substitution Principle, you might think that it is a violation of it as well, which is absolutely true.
In fact, SOLID principles are so close to each other. The ISP is particularly related to the Liskov Substitution Principle, and arguably, the ISP has the same goal as the Single Responsibility Principle. We will dig into these relations later in this article_._
For now, before jumping ahead to fix the previous violation, let me first discuss what if you violate the ISP.
Violation of the Interface Segregation Principle
Have you ever worked on a legacy application and its users regularly request new features? Don’t you agree that it is more likely in that case that you would ignore the recommended design principles? For example, it is easier to add a new method to an existing interface even though it has already different responsibilities. That’s a big problem, it’s often the beginning of Interface Pollution.
Interface Pollution is adding new methods to an existing interface that your client doesn’t actually need. You can note this pollution if you have an empty implementation for an interface method or if you have to throw a
_UNSUPPORTED_OPERATION_
exception like the previous example.
Having said that, by violating the Interface Segregation Principle you would face these problems:
- A change in a large interface is going to force you to change clients that implement it even though they don’t need it.
- Large interfaces end up with more coupling, which makes your code more brittle and hard to maintain.
- Clients that depend on interfaces they don’t actually need tend to be difficult to test. As these clients are likely to be more coupled and need extra work to test methods they actually don’t need.
- As a result of this tight coupling, your code tends to be riskier to deploy.
- Violating ISP eventually ends up with a violation of other principles like SRP and LSP.
On top of that, keep in mind, that every time you’re ignoring the recommended design principles, you’re falling into the trap of Technical Debt.
Great, after knowing these problems, we shouldn’t leave our example as is, let’s know some practical ways to follow the ISP and meanwhile fix the previous example.
How to Apply the Interface Segregation Principle
In fact, you have three options to apply the Interface Segregation Principle, and every option depends on the situation you have:
1- Breaking Up Large Interface
When should you choose this option?
- If you know a lot about your business requirements, and now you are building a new interface that proceeds with these requirements, instead of building a new fat interface, you should keep in mind to build small, cohesive ones.
- If you have an existing fat interface and breaking it up wouldn’t affect significantly on the codebase.
How can you apply this option?
Let’s apply this option by fixing the previous example. Here, you have the IRegister
interface, yes maybe it is small in this example but it actually lacks cohesion. Obviously, that’s because there is no password implementation in the social registration, which means the hashPassword
method in the SocialRegister
class is not relevant (not cohesive).
So, let’s refactor this example by applying the ISP.
class User {}
// Breaking up the large interface into small ones
interface IRegister {
register(): User;
}
interface IPasswordHashing {
hashPassword(): string;
}
// Now, it is up to your clients to implement what they actually need
class PasswordRegister implements IRegister, IPasswordHashing {
register(): User {
return;
}
hashPassword(): string {
return;
}
}
class SocialRegister implements IRegister {
register(): User {
return;
}
}
As you see, I broke up the loosely-coupled interface IRegister
into two small and cohesive interfaces IRegister
and IPasswordHashing
. Now, in the case of PasswordRegister
client, you need both implementations but in the case of SocialRegister
client, you just need the IRegister
implementation and not the IPasswordHashing
implementation.
By applying this refactoring, your classes (clients) aren’t forced to implement methods they don’t actually need. If you want to add a new registration process, now it is up to you to apply the password implementation or not, simply, implement the IPasswordHashing
interface or leave it, on top of that, you don’t have to throw a UNSUPPORTED_OPERATION
exception to force implementing it.
2- Multiple Interface Inheritance
When should you use this option?
You should go with this option if you are forced to deal with legacy code that’s already using a fat interface (you control), and if you replace it with smaller interfaces, your code’s going to break. So, you have to keep its signature for backward compatibility.
And since you can’t go and fix all those places, you can’t depend on technique number 1.
Let’s jump into an example to clarify this point:
interface ISendNotification {
sendEmail: () => void
sendSms: () => void
}
class Old implements ISendNotification {
sendEmail = () => {}
sendSms = () => {}
}
Consider the ISendNotification
interface as a fat and loosely coupled interface. And this interface is actually implemented by many classes (clients), and refactoring these classes is time-consuming and error-prone.
How can you apply this option?
Think of it, you have many places that depend on this interface, so you should keep its signature to avoid breaking the existing code, right? As a result, we should keep the old classes as is and any new classes should implement new small, cohesive interfaces instead. To do that, we can use the multiple interface inheritance.
// Small, cohesive interfaces
interface ISendEmailNotification {
sendEmail: () => void
}
interface ISendSmsNotification {
sendSms: () => void
}
// Multiple interface inheritance
interface ISendNotification extends ISendEmailNotification, ISendSmsNotification {}
// Old classes
class Old implements ISendNotification {
sendEmail = () => {}
sendSms = () => {}
}
// New classes
class New1 implements ISendEmailNotification {
sendEmail = () => {}
}
class New2 implements ISendSmsNotification {
sendSms = () => {}
}
As you see, now, the Old
class keeps implementing the ISendNotification
to avoid breaking any existing code. Meanwhile, any new classes like New1
and New2
would implement the new broken interfaces. I broke up the loosely-coupled interface ISendNotification
into smaller interfaces ISendEmailNotification
and ISendSmsNotification
and emptied its body to keep just its signature.
Thanks to the multiple interface inheritance that enabled us to do this technique.
3- The Adapter Design Pattern
When should you use this option?
You should use this technique if you are forced to use a fat interface that you don’t have control over, like interfaces coming from a third-party library or SDK.
Of course, in such a case the previous techniques wouldn’t work.
How can you apply this option?
Since you can’t rid of this fat interface, you should create small, cohesive interfaces that follow the adapter design pattern, and your code should work with these interfaces that you control, and they, in turn, can work with the original fat interface. Meanwhile, only the adapter should know about the underlying fat interface.
class User {}
class Report {}
// Fat interface coming from a third-party
interface IThirdPartyService {
getUsers(): User[];
addUser(user: User): void;
generateReport(): Report;
scheduleEmailReminder(reminderType: "daily", userId: number): void;
sendPromotionalOffer(promotionType: string, userIds: number[]): void;
}
// Adapter class
class ThridPartyServiceAdapter implements IThirdPartyService {
getUsers(): User[] { return []; }
addUser(user: User): void {}
generateReport(): Report { return {}; }
scheduleEmailReminder(reminderType: "daily", userId: number): void {}
sendPromotionalOffer(promotionType: string, userIds: number[]): void {}
}
// Small, cohesive interfaces
interface IUserService {
getUsers(): User[];
addUser(user: User): void;
}
interface IReportingService {
generateReport(): Report;
}
interface IEmailService {
scheduleEmailReminder(reminderType: "daily", userId: number): void;
}
interface IPromotionsService {
sendPromotionalOffer(promotionType: string, userIds: number[]): void;
}
// Your internal code
class InternalService implements IReportingService {
private adapter: ThridPartyServiceAdapter;
// Use the Dependency Injection to inject the adapter in your class
constructor(adapter: ThridPartyServiceAdapter) {
this.adapter = adapter;
}
generateReport(): Report {
return this.adapter.generateReport();
}
}
class OtherInternalService implements IEmailService, IUserService {
private adapter: ThridPartyServiceAdapter;
constructor(adapter: ThridPartyServiceAdapter) {
this.adapter = adapter;
}
getUsers(): User[] {
return this.adapter.getUsers();
}
addUser(user: User): void {
return this.adapter.addUser(user);
}
scheduleEmailReminder(reminderType: "daily", userId: number): void {
return this.adapter.scheduleEmailReminder(reminderType, userId);
}
}
Imagine that the IThirdPartyService
is coming from a third-party library and you have no control over it. So you can’t break it up. To work around this issue:
- you can adapt this fat interface to work in your code via the adapter
ThridPartyServiceAdapter
which is the only part of your code that should know about this fat interface. - Afterward, you can create small, cohesive interfaces
IUserService
,IEmailService
, andIPromotionsService
that could be implemented directly in your code instead of the fat interfaceIThirdPartyService
. - On top of that, you can use the adapter
ThridPartyServiceAdapter
in your classes using the Dependency Injection, which is a perfect solution in such a case.
Having said that, now, your code is using the third-party library through an under-control adapter, and at the same time doesn’t know anything about the third-party library itself.
Sounds good, but how can you detect that your code has problems, let’s discuss some code smells that indicate that your code is violating the ISP.
Code Smells for Interface Segregation Principle Violation
Large Interfaces
Large interfaces are harder to fully implement and, thus, more likely to only be partially implemented. As a result, you are most likely to violate the ISP.
You might argue about the Large keyword. Maybe you’re right, it might be vague, but here I am talking about the probability of violation. So to have the ideal formula, you can consider both the Largeness and Cohesion of the interface.
Take a look at the first example, clearly the IRegister
interface is not large but less cohesive which in turn leads to the ISP violation.
Methods Throwing Exceptions
What is the easiest implementation you will do for a large interface you don’t need? Yes, to throw a UNSUPPORTED_OPERATION
exception.
Look at the first exampleSocialRegister
class, it actually doesn’t need to implement the hashPassword
method, since it isn’t relevant to social registration. So, to work around this issue, the hashPassword
method throws a UNSUPPORTED_OPERATION
exception which in turn violates the ISP.
Methods With Empty Implementations
You might choose to leave a not-needed method blank instead of throwing an exception. That also violates the ISP.
After reaching out at this point, you might end up thinking: Great, there is no need for many-methods interfaces, all I need are just single-method interfaces to avoid the ISP violation completely.
To What Extent You Should Apply Interface Segregation Principle?
If you think twice about this conclusion, you could end up with a bunch of single-method interfaces that are much more difficult to use, group together, and understand as your code would be very complicated especially if it was a large codebase.
So, what should you do instead? There are two options:
- If your business requirements are clear and you know them exactly upfront, you should consider Cohesion and Largeness when you’re building your interfaces. The more experience you have, the more accurate you reach the balance point.
- If you’re building a project from the beginning and you don’t know the entire scope of it, you should consider the (PDD) Pain-Driven Development principle. Don’t you remember the PDD principle we explained in the Single Responsibility Principle article? Simply put, you should build your interfaces as simple and cohesive as you can. You shouldn’t break up interfaces or use the adapter design pattern just to satisfy the ISP but, rather, look for places in your code where the app is painful to work with as your app grows, then, see if you can apply the ISP here to improve your design and mitigate this pain.
Let’s move forward to some interesting points. I stated that ISP is closely related to the other SOLID principles. Let’s discuss its relation to LSP and SRP.
Difference Between Liskov Substitution and Interface Segregation Principles
Let’s recall what is the primary goal of the Liskov Substitution Principle, the LSP brings additional constraints to the Object-Oriented Design and states that these relationships (Inheritance or Composition) aren’t sufficient and should be replaced with IS-SUBSTITUTABLE-FOR.
That means the primary goal of LSP is to substitute between a SuperType and a SubType without breaking the existing code.
Great, by knowing that, let’s return to our first example, I said that this example is violating the LSP, but why?
Yes, as you might’ve guessed, the SocialRegister
class (SubType) throws an UNSUPPORTED_OPERATION
exception and partially implements the IRegister
interface (SuperType). In other words, the SuperType IRegister
and SubType SocialRegister
are not substitutable, which violates the LSP.
But how can you solve this LSP violation in that case? The short answer is by applying the Interface Segregation Principle.
As a result, in my opinion, the Interface Segregation Principle is just a technique for correctly applying the Liskov Substitution Principle.
But what about the Single Responsibility Principle?
Single Responsibility Principles vs Interface Segregation Principle
In his article, Mark Seemann states that:
Although I do understand the subtle differences between SRP and ISP I think they are so closely related that one of them is really redundant. We can remove the ISP and still have a fairly good acronym: SOLD (although SOLID is still better).
To know why he said that, let’s compare the two principles from two perspectives, the Context, and the Goal of both.
- Context Perspective You might argue: _This is aggressive from Mark Seemann. There is a clear difference between the two principles, the SRP is concerned with classes while the ISP is concerned with interfaces. _In my opinion, that’s not correct. What will you do if you use an abstract class instead of an interface? In that case, are you going to ignore the ISP? For me, SOLID principles are more mindset than just guides I have to follow. I think SRP should be applied to both classes and interfaces. Meanwhile, ISP isn’t exclusive to just interfaces, but classes as well. So, I think the context of the two principles is the same.
- Goal Perspective Let’s recall what is the goal of the Single Responsibility Principle, your classes and interfaces should have one responsibility and one reason to change, which means SRP encourages you to prefer small, cohesive components to large, loosely coupled ones. Think of an example, in which you violated the ISP by defining a fat interface that has multiple methods and these methods aren’t cohesive and not related to each other. It is clear that implementing this interface would eventually lead to the Single Responsibility Principle violation as well, isn’t it? So, I think the goal of the two principles is the same as well.
Having said that, could you tell me the difference between SRP and ISP? For me, there is a blurred line between Single Responsibility and Interface Segregation Principles. It is better to say, ISP is a small part of SRP or ISP is just a technique to correctly apply the SRP.
After reaching out at this point, what do you think, it is better to be SOLID or SOLD as per Mark’s saying?
Benefits of the Interface Segregation Principle
I know what is on your mind, No, I’m not contradictory, I am not against the ISP, rather I am against making it a standalone principle to make a catchy and fancy SOLID acronym.
As I said, I think of Interface Segregation Principle as a technique to correctly apply the other SOLID principles.
But if you think of it this way, then it actually has some benefits:
- No fat interfaces with multiple responsibilities.
- Your implementing classes would be concerned only with the methods they indeed need.
- Your code would be more maintainable, testable, and extendable.
- Eventually, avoiding violating other principles like LSP and SRP.
Conclusion
In this article, we have learned the fourth principle in SOLID which is Interface Segregation Principle.
We learned that you should prefer the small, cohesive interfaces to the large, loosely-coupled ones.
Then, we knew some practical ways we can follow to correctly apply the ISP like breaking up the large interfaces, multiple interface inheritance, or using the adapter design principle.
Afterward, we learned about some code smells that might indicate your code is violating the ISP.
Finally, we learned about the relationship between this principle and other principles like SRP and LSP.
Resources
- SOLID Design Principles Explained: Interface Segregation with Code Examples
- Interface Segregation Principle in Java
- Interface Segregation Principle: Everything You Need to Know
- SOLID Principles: The Interface Segregation Principle
- The Interface Segregation Principle
- SOLID Principles for C# Developers
Before you leave
If you found this article useful, check out these articles as well:
- 4 Ways To Handle Asynchronous JavaScript
- Strategy vs State vs Template Design Patterns
- MongoDB GridFS, Made Simple
Thanks a lot for staying with me up till this point. I hope you enjoy reading this article.
Top comments (0)