A Guide to SOLID Dependency Inversion
This article assumes that you have a basic understanding of object-oriented programming and its implementation in any OOP language such as C# or Java.
S.O.L.I.D design principles encourage developers to create more understandable, maintainable and flexible object-oriented software. Today, we take a look at dependency inversion. Robert C. Martin in Agile Software Development, Principles, Patterns, and Practices gave the following guidelines to dependency inversion:
A. High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g. interfaces).
B. Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
The first time I saw those, I felt blank. I had questions:
- What are low-level modules and high-level modules? How do you recognize them?
- What are abstractions and concrete implementations?
- How does an interface create abstraction?
Let’s go over some terms extracted from quotes A and B above. We’d discover that the dependency inversion principle is fun to learn.
Term 1: Modules
Let’s keep things simple. We’ll look at modules as classes in this context. e.g. class Animal {}
or class Car {}
.
Modules are a set of independent units that can be used to construct a more complex structure.
— Lexico
Thus, seeing the dependency inversion principle in the light of classes is perfect because classes themselves are a set of methods and properties used to construct more complex structures.
Term 2: Abstraction
Abstraction makes essential features available without including background details. High-level modules (you could say “high-level classes") do a lot of abstraction.
Term 3: High-level Modules
In the world of computer programming, “high-level” means “many details concealed". For example, to query a “customers” table for all records using Laravel framework is as easy as:
Customers::all();
This is high-level because a lot of details is concealed in that line of code. What Laravel does in the background is establish a connection to a database, run SELECT * FROM CUSTOMERS as SQL query, fetch all rows, return them as an array of objects and close the database connection. So much for one line of code!
Thus, anything “high-level” does a lot of abstraction.
If a class A is not doing everything all by itself, but depends on one or more classes to aid its operations, class A is most likely a high-level module.
Term 4: Low-level Modules
In computer programming, “low-level” means “detailed”. Everything is bare and there are no abstractions. This is why a low-level language such as Assembly Language does not abstract memory management, whereas memory management is concealed when you’re writing high-level languages like Java and PHP.
If a class B does everything all by itself and does not depend on any other class to aid its operations, class B is most likely a low-level module.
Now let’s dive into interfaces. How can an interface be used as an agent of abstraction?
Interface
Implementing an interface allows a class to become more formal about the behavior it promises to provide. Interfaces form a contract between the class and the outside world, and this contract is enforced at build time by the compiler. If your class claims to implement an interface, all methods defined by that interface must appear in its source code before the class will successfully compile.
Inheritance in object-oriented programming enables an interface A to extend an interface B. The major purpose of an interface is to define methods and properties for any class that implements it. As explained in the Oracle Java Documentation cited above, the compiler throws an error if it does not find all the methods defined by an interface in the class that implements that interface. Imagine that we have a Vehicle interface and a Car interface. While any vehicle (either a car or an airplane) should be able to start and accelerate, honking is not needed in an airplane. Thus, adding it to vehicle interface would mean forcing every vehicle to honk, including a Boeing airplane. So we have to be strategic about what we enforce. Below, we create a vehicle interface where we define functions to be enforced on every vehicle and go further to create a Car interface that extends Vehicle. This way, we can add extra requirements peculiar to cars such as honking and drifting.
interface Vehicle
{
public function start();
public function accelerate();
}interface Car extends Vehicle
{
public function honk();
}class Tesla implements Car
{
public function start() {
print("Car has started");
} public function accelerate() {
print("Car is accelerating");
} public function honk() {
print("Car is honking");
}
}
For an airplane, we’d have:
interface Vehicle
{
public function start();
public function accelerate();
}interface Airplane extends Vehicle
{
public function fly();
}class Boeing implements Airplane
{
public function start() {
print("Airplane has started");
} public function accelerate() {
print("Airplane is accelerating");
} public function fly() {
print("Airplane is flying");
}
}
While a concrete class (e.g.
class Boeing {}
) can implement an interface, an interface cannot extend any other class asides another interface.
Dependency Inversion is Easy
Imagine that we have a car powered by gasoline which can get an extra power source if we pay for an upgrade at the car company. Let’s create a gasoline class and inject it as a dependency into our “Car” class.
// This is a concrete class (or concretion)class Gasoline
{
public function startEngine() {
print("Engine ignited");
}
}// This is another concrete classclass Car implements Vehicle
{
protected powerSource; public function constructor(Gasoline gasoline) {
this.powerSource = gasoline;
} public function start() {
this.powerSource.startEngine();
print("Car has started");
} public function move() {
print("Car is moving");
}
}
The code works. But because a concrete class depends on another concrete class, we have flouted the dependency inversion principle, as well as made our car “UNSCALABLE”. We can’t add a new power source without modifying our “Car" class because the class is glued to gasoline. Let’s do it the scalable way — the dependency inversion way.
We’ll create an interface called PowerSource that will be implemented by all power source classes.
// This is an abstractioninterface PowerSource
{
public function startEngine();
}// This is a concrete classclass Gasoline implements PowerSource
{
public function startEngine {
print("Engine has started and is running on gasoline");
}
}// This is another concrete classclass Electricity implements PowerSource
{
public function startEngine {
print("Engine has started and is running on battery power");
}
}
Now, let’s inject PowerSource interface into our “Car” class. That way, the car class does not depend on another concrete class. It will accept any class injected via its constructor as long as the injected class implements PowerSource interface. Because all power source classes must have a startEngine() method, our “Car” class becomes scalable — it can accept any energy source.
class Car implements Vehicle
{
private powerSource; public function constructor(PowerSource source) {
this.powerSource = source;
} public function start() {
this.powerSource.startEngine();
} public function move() {
print("Car is moving");
}
}
To create a new car object is as simple as instantiating Car class and passing any power source to its constructor. Whatever power source is available, Car class can handle it.
PowerSource gasoline = new Gasoline();
PowerSource electricity = new Electricity();
PowerSource hydrogen = new Hydrogen();var car = new Car(gasoline);
car.start();
// Engine has started and is running on gasolinevar car = new Car(electricity);
car.start();
// Engine has started and is running on battery powervar car = new Car(hydrogen);
car.start();
// Engine has started and is running on hydrogen gas
What Has Happened?
We have abstracted our power source from the Car class. This way, Car class does not need to know what power source it is using.
I am a Car. As long as the dependency injected into me implements PowerSource interface, I have energy to carry out my operations. My developer will never need to modify any of my functions to make me utilize a new energy source. I’m scalable by default.
Although the “Car” class depends on a startEngine()
method to function, the method is supplied via an interface. The beauty is that when more power sources are made available, each power source class only needs to implement PowerSource interface — and we’ll never ever have to modify our “Car” class to make room for a new power source.
Thank you for reading. I’d love to read your questions and feedback.