Interface hell: The Interface Segregation Principle doesn't mean “Add interfaces everywhere”
Most developers are aware of SOLID at this point, and "I" stands for "Interface Segregation Principle". And like a lot of the other principles in SOLID, it has been wildly misunderstood.
Recently, I've seen the following pattern:
interface InsuranceService {
fun addPolicy(policy: InsurancePolicy)
fun getPolicyDetails(policyNumber: String): InsurancePolicy
fun calculatePremium(policy: InsurancePolicy)
fun processClaim(policy: InsurancePolicy)
fun cancelPolicy(policyNumber: String)
}
@Service
class InsuranceServiceImpl(private val policyRepository: PolicyRepository) : InsuranceService {
override fun addPolicy(policy: InsurancePolicy) {
// Implementation
}
override fun getPolicyDetails(policyNumber: String): InsurancePolicy {
// Implementation
}
override fun calculatePremium(policy: InsurancePolicy) {
// Implementation
}
override fun processClaim(policy: InsurancePolicy) {
// Implementation
}
override fun cancelPolicy(policyNumber: String) {
// Implementation
}
}
The code above is some fictional Kotlin code, but the pattern is the same. Let's go through what is happening:
- The interface is in the same file as the implementation (Yes, the above represents code found in the same file).
- There's always 1-to-1 between the public methods in the implementation and interface classes.
- There's always a single implementation of a given interface.
So, what's the problem here? After all, it is said that we should code against interfaces and not implementations. Doesn't this pretty much guarantee that we end up coding against the interface? Isn't that a good thing?
Ask yourself: What value does the interface above provide? It provides no benefits while being annoying to work with.
The Interface Segregation Principle says the following:
Clients should not be forced to depend upon interfaces that they do not use.
There are two things we need to unpack in that statement. Who are the clients? And what does it mean by "interfaces"?
- Clients are whoever is using the class, like another class.
- "Interfaces" in this context means "methods". I.e. clients should only know about the methods they use.
If we apply the principle to the InsuranceService
, we see that it is clearly violated as all clients must know about all methods because they are present in the interface. If we are going to adhere to the Interface Segregation Principle, then we'd end up with interfaces looking something like this:
interface PolicyManager {
fun addPolicy(policy: InsurancePolicy)
fun getPolicyDetails(policyNumber: String): InsurancePolicy
fun cancelPolicy(policyNumber: String)
}
interface PremiumCalculator {
fun calculatePremium(policy: InsurancePolicy)
}
interface ClaimProcessor {
fun processClaim(policy: InsurancePolicy)
}
While not perfect, we have split the interface into more concrete (and often more helpful) interfaces. This is how we should think about interfaces - small and concrete. But context matters, and in the context of the scenario in the first code example, this doesn't provide much value. An interface needs to have a purpose. It needs to have a reason to exist. We shouldn't write interfaces for the sake of having them - they should provide some value beyond just existing.
NOTE
Let's assume we're working on some closed-source application where the interfaces will never have a client we don't directly control. IF we were to have external clients, the interfaces above might yield some benefits just by existing.
Let's look at some good reasons why we would want to use an interface:
- We may want to decouple two classes (More on this in a bit).
- We may need to treat different classes as the same class, so you throw them into the same interface - which requires that we have multiple implementations of an interface.
- We may want to make testing easier.
- We may want to have a cleaner API to work towards. These might not be all the reasons, but they are the most common. I won't review each in great detail, but I want to touch on a couple.
Interfaces & Decoupling
Decoupling can be great - in fact, it often is great! Does that mean we should strive to decouple everything everywhere? No, I don't think so.
Developers have a habit of going to extremes. If we look at the example in this post, the team has decided to interface everything - which is extreme. So, they have decoupled usage from implementation. Now what?
There are really two reasons you want to decouple:
- Being able to replace the type easily (which requires you to have multiple implementors. We'll talk more about testing later.)
- Use IoC to reverse module dependency.
If you aim to reverse a dependency, then again, you have to ask why. The reason is that you want to get some architecture. For example, if we look at Hexagonal architecture, we see that the database "layer" exists outside the application code, yet the application code must somehow get data from the database. How can we do that? We put the interfaces for getting, deleting, persisting, querying, etc, inside the application layer! Then, we have the database layer implement those. That way, the application layer doesn't need to know about the database layer, not even as a build-system dependency. The application layer doesn't need to know about any implementors of these interfaces.
The effect here is that the implementations of the interfaces are found in a different module from where the interfaces are defined. By doing so, we can control the direction of the dependency as we like.
There might be other reasons we want to reverse a dependency (not just module dependency). It could achieve some pattern or flow. However, it is always because we want to achieve something. We reverse the dependency because it helps us. We do it because it helps us in some way.
Interfaces & Testing
Whenever I bring up the pointlessness of many of the interfaces, I see the response often is, "But what about testing?". Yeah, what about it?
If I were a betting man, I'd say this question comes from advice from the past when dependencies were complex to replace. These days, we have powerful mocking tools* to replace the dependency, regardless of whether it is an interface or an implementation. It is, for the most part, a non-issue.
*Whether you'd want to mock stuff is a post for another day
Interfaces & "Just in case"
Another common argument is that we can't predict the future. We have yet to determine how the codebase will evolve, so it is better to use interfaces and not need them while hoping that they come into use at some point. This is a flawed argument on multiple levels.
For one, if we don't know how the application can evolve and we want to "future-proof" it, then we can't make good interfaces either. We don't know how they'll end up looking.
The second flaw is that it is so easy to create an interface when the need shows up. We have powerful refactoring tools that can both extract the interface and replace the usages of the concrete implementation.
The third issue is that we write code for the need that exists right now - not some theoretical future. Sometimes, we need to stop and consider the trajectory of our system so that we don't design ourselves into a corner, but for the most part, we focus on building the right thing for what is needed right now.
In other words, I don't buy this argument whatsoever.
Wrapping up
There are plenty of articles titled "Code against interfaces, not implementations", and it has become a slogan for some developers. Most of the articles communicate that the title doesn't automatically mean "let's interface everything", which is great. Unfortunately, if there exists a clear and actionable title, then some developers will just do what the title says. Developers sometimes struggle with nuances.
In the future, if you're going to make an interface, ask yourself why. What value does it give you? How does it enable you to do something? Don't create the interface if the answer is "nothing" or "don't know".
/Rant