1. Introduction
At the beginning of our professional career as software developers, most of us program to implementations.
Later on, either intuitively or because of need, we slowly tend to change this mindset. We write more and more code to abstractions and interfaces.
In this tutorial, we’ll see what these terms mean and what pros and cons do they have.
2. Case Study
To show the different techniques, we’ll walk through a few simple tasks. Remember the game series Big Car Stealing? We’re lucky enough to be the developers of the newest version.
2.1. Wiring Implementations Together
The first task to solve is to make the character drive the cars. Our employer gave us absolute trust to handle the situation the best way we see.
For the first proof of concept, we’re focusing only on speeding up and slowing down.
Our first intuition is to create two classes: Player and Car:
The only thing we need to do is to invoke the drive() method of the Player class and pass a Car instance.
We implement everything, and it works like a charm. However, we get our next task. On top of driving a car, we must support driving a truck, as well.
No problem, we introduce the Truck class and the Player.drive(Truck) method:
So far, so good. But after that, we face the next task: driving boats. (Wait, what? Boats? We thought the game is about cars. Weird.)
We could repeat the same process we did with the truck. However, we start wondering: how many other things should we able to drive? We look at the backlog and see 31 different driveable things coming up (including bicycles, planes, submarines, jetpacks, and even space stations).
Therefore, we need a different approach. How could we do better?
2.2. Using Abstraction
Abstractions to the rescue! We decide that we’ll create an abstract Vehicle class, which will be the superclass of the Car, Truck, Boat, and all future classes.
Also, this way, we’ll need a single drive(Vehicle) method in the Player class:
The next task we get is to handle accidents in the game. During these accidents, the vehicles suffer damage. To support this, we introduce new methods:
However, since the method names break() and brake() are so similar, we confused them, which caused a frustrating debugging session. When we finally find the problem’s source, we propose a question. Can we somehow hide those methods we don’t need?
We get the idea to create multiple base classes for different scenarios: Driveable and Breakable. We took a class about the BDecreased programming language in the university, which allowed multiple inheritances. However, now we use the HotBrownStuff language, which doesn’t support that (for good reasons). How can we proceed then?
2.3. Using Multiple Abstractions
HotBrownStuff has a new concept over BDecreased: interfaces. A class can implement multiple interfaces, which makes it possible to solve the problem with the following class diagram:
We’re close to burnout because of the amount of refactoring we have to do every time a new feature comes. But the development must go on, so we get our next assignment: when we hit a building with a vehicle, it should suffer damage, too. We take a deep breath and get ready to rewrite half of the codebase again.
However, when we think about solving it, we find a straightforward implementation.
We make the building implement the Breakable interface:
To our surprise, everything is working fine without much effort. The birds are chirping again. The sun is shining. We even smile again. We can thank all of this for a better architectural design.
2.4. Summarizing the Case Study
What was the difference between the techniques we used?
First, we directly used the implementing classes from other classes. Usually, we call this method “programming to classes” or “programming to implementations”.
It makes the code tightly coupled because there’ll be many dependencies between different classes. This makes the code fragile because when we modify one part of the code, it tends to break things in many unexpected places.
Next, we introduced an abstract class, which decoupled our classes’ clients from the concrete implementations. We call this technique “programming to abstraction”.
But we were still mixing different aspects of the functionality in the same abstraction.
Last, we introduced multiple abstractions: interfaces. We call this method “programming to interfaces”. Note that an interface is also an abstraction. Therefore, this method is the subset of programming to abstractions.
With interfaces, on top of decoupling implementations, we were able to decouple multiple concepts.
In a nutshell, when we’re programming to interfaces, the different business logic parts are not connected through implementations. They’re connected through interfaces.
3. Consequences
Let’s start with the cons. We have to create much more types: interfaces, classes, sometimes abstract classes. It may be overwhelming at first, but we can manage this if we use a good folder/package structure.
Also, we’ll need an external component to instantiate the implementations. Preferably, it’s in the infrastructure and not in the business logic. We’ll take back to this topic in the section about design patterns.
But it’s a small price comparing to the pros. We’ll explain those benefits in detail in the following sections.
3.1. Unified Methods
Think about the example with the car, truck, and boat. We used the same method names for all of those, but we could easily name the methods differently without a common ancestor. For example, accelerate(), speedUp(), and goFaster() are all valid candidates to name the same functionality. We could mix those in the different classes. For example, the car could accelerate, the truck speed up, and the boat goes faster.
With abstractions, we declare a contract between classes. The contract states what kind of operations the implementation will provide to the client. It doesn’t say a thing about how those operations are working, though – which is a good thing. This way, we can focus on what we want to do instead of how we can do that.
3.2. Hiding the Implementations
Only the interface is visible to other parts of the business logic. We should strive to keep these interfaces small, simple, and straight to the point to increase cohesion. In other words, with abstractions, we introduce boundaries between different parts of the application.
If we do this, we won’t accidentally leak implementation details, which tend to introduce tight coupling between different components. This would make refactoring and modification hard. Also, it’ll make the code harder to understand.
On top of that, since we don’t see (at first sight) the implementing classes, only the abstraction (therefore, the contract). Therefore, we can more easily comprehend the logic of the code. Again, we can focus on what the class does instead of how it does that. Also, we don’t have to keep in mind the names and responsibilities of dozens of classes. The abstraction hides all those details.
For example, the JDBC API defines many interfaces and a single class. The JDBC drivers implement those interfaces. However, we don’t use those classes from our application code. We only use the core JDBC types.
3.3. Testability
Louse coupling and fewer responsibilities make the code more testable.
Since we depend on interfaces, we can easily pass test doubles instead of concrete implementations. Also, since these interfaces are smaller and have well-defined responsibilities, providing mocks for those is straightforward.
3.4. Introducing Multiple Implementations
We also saw that we could introduce new implementations without modifying the client code. It’s a powerful concept because we can extend the details in any way we please. If we have new requirements, we can get rid of the old implementation and replace it with a new one. For example, if we abstract the data access layer, we can switch from an SQL database to a graph database without changing the business logic.
JDBC also relies on this concept. If we decide to use a different database engine, the only thing we need to do is to replace the JDBC driver. The application code will stay the same because it’s independent of the implementing classes.
4. Connection to SOLID Principles
Programming to interfaces makes it easier to follow multiple SOLID principles.
- Single Responsibility Principle: By creating small interfaces, we define obvious responsibilities for implementing classes. It makes it easier to follow the SRP, especially when we make our classes implement only a handful or even a single interface.
- Open/Closed Principle: With loose coupling and hidden implementations, following OCP is also more straightforward. Since the client code doesn’t rely on the implementation, we can introduce additional subclasses as needed.
- Liskov Substitution Principle: LSP is not directly connected to this technique. However, we must take care when we’re designing our inheritance hierarchy to follow this principle, too.
- Interface Segregation Principle: ISP isn’t a result but a good practice to follow when we’re programming interfaces. Note that we already talked about the importance of defining small, well-defined responsibilities. Those notes were hidden hints to follow ISP.
- Dependency Inversion Principle: By relying on abstractions, we already did the majority of the work to follow DIP. The last thing to do is to expect dependencies from an external party instead of instantiating them internally.
Note that with only depending on interfaces, this last step is already inferred. We cannot instantiate a class without depending on it.
5. Related Design Patterns
We already mentioned that we need some infrastructure to instantiate the implementations. Usually, we solve this problem using the Factory, Factory Method, Static Factory Method, Abstract Factory, and Builder patterns. Using Dependency Injection (preferably through a framework, like Spring, CDI, or Guice) makes this straightforward.
Of course, depending on the requirements, we may mix some of these patterns. It depends on the exact problem at hand.
The previous patterns make it easier to program interfaces. On the other side of the coin, some patterns rely on abstractions. Therefore, programming to interfaces will make it easier to use them. A few examples are Adapter, Composite, Decorator, Proxy, Mediator, Observer, State, Strategy, and Visitor.
6. Conclusion
Programming to interfaces looks tedious to beginners because of the higher number of interfaces and classes. Also, introducing many dependency hierarchies may be strange at first, too.
However, we’re following good OO practices and principles. Programming to interfaces will make our application loosely coupled, more extensible, more testable, more flexible, and easier to understand. It takes time and practice to master it, but it’s worth the effort.