In one of my previous articles I made a brief introduction to the SOLID principles for Object Oriented Software Development
In today’s article, I’d like to cover the Dependency Inversion Principle (DIP) in more depth, starting by explaining what it is, how it’s supposed to be used, and what are its concrete benefits.
What Dependency Inversion Is
So, let’s consider a house with an old mailbox. We could model that in our code like this:
class VintageMailbox {}
class House {
private mailbox = new VintageMailbox();
}
const h = new House();
In this example, class House
depends on class Mailbox
.
This is the most common way of writing classes, because it is really easy to just create the new instance of our class where we need it.
The Dependency Inversion principle proposes that, instead of depending on concrete instances, we should depend on abstractions, like an interface or an abstract class. We can achieve that by using Dependency Injection:
interface Mailbox {}
class VintageMailbox implements Mailbox {}
class House {
constructor(private mailbox: Mailbox) {}
}
const mailbox: VintageMailbox = new VintageMailbox();
const house = new House(mailbox);
In this updated example, class House
expects any class that implements Mailbox
. This is possible with the introduction of the Mailbox
interface, making classes VintageMailbox
and House
depend on it.
In non-typed languages like Ruby, there are no interfaces, but we can still apply the same idea by relying on shared behaviour.
class VintageMailbox
end
class House
def initialize(mailbox)
@mailbox = mailbox
end
end
mailbox = VintageMailbox.new
house = House.new(mailbox)
Here, House
doesn’t care what mailbox
is, as long as it responds to the methods House
needs. That means we can pass in any object with the right methods, and House
will work without knowing its exact class. This is dependency inversion in a duck-typed way.
How to set it up
The code above is the typical example of the Dependency Inversion Principle, but it doesn’t show how to use it in a real project.
To better show this, let’s consider three layers of dependency: a house has a mailbox, and a mailbox has a lock. The code below shows what it looks like when we don’t follow the Dependency Inversion Principle. First, we’ll start by ensuring that we have all our dependencies:
// padlock.ts
class Padlock {}
// smartLock.ts
class SmartLock {}
// vintageMailbox.ts
class VintageMailbox {
private lock = new Padlock();
}
// modernMailbox.ts
class ModernMailbox {
private lock = new SmartLock();
}
It’s worth a closer look at the House class, since this is where we put the logic to choose which mailbox to use:
// house.ts
class House {
private mailbox;
constructor(type: "vintage" | "modern") {
if (type === "modern") {
this.mailbox = new ModernMailbox();
} else {
this.mailbox = new VintageMailbox();
}
}
}
And finally, we just create our instances of House
in our main.ts
file:
// main.ts
const house1 = new House("vintage");
const house2 = new House("modern");
In order to follow the Dependency Inversion Principle, we still need to create specific Mailbox and Lock classes, just not inside other classes. Like before, we’ll start by creating our dependencies. Note how, in this case, they don’t depend on specific classes, but only on the new interfaces that we also created here:
// lock.ts
interface Lock {}
// padlock.ts
class Padlock implements Lock {}
// smartLock.ts
class SmartLock implements Lock {}
// mailbox.ts
interface Mailbox {}
// vintageMailbox.ts
class VintageMailbox implements Mailbox {
constructor(private lock: Lock) {}
}
// modernMailbox.ts
class ModernMailbox implements Mailbox {
constructor(private lock: Lock) {}
}
Now our House
class becomes really simple, as it doesn’t need to know anything about specific Mailbox types:
// house.ts
class House {
constructor(private mailbox: Mailbox) {}
}
And finally, we specify the concrete class instances that we’ll need close to our system’s entry point, which corresponds to main.ts
in this case:
// main.ts
const house1 = new House(new VintageMailbox(new Padlock()));
const house2 = new House(new ModernMailbox(new SmartLock()));
Having covered what dependency inversion is and how we can implement it, let’s see why we should bother.
Why Dependency Inversion Matters
System configuration
In the previous section we saw how simple our code becomes when we decide what concrete classes to use at the top of the system rather than buried deep in the code.
I must admit that, when I first learnt this concept, I was really confused for two reasons: I thought that making changes at the top level was still a violation of the Open-Closed Principle; and I could not see why defining the low-level dependencies at the top level would help.
After reading a lot on the topic and also talking to several colleagues, I managed to solve the apparent paradox and understand the value of this.
First, if we want to change how our system behaves, we’ll have to change our systems somewhere. The Open Closed Principle, which deserves an article for itself, is not about not changing our code at all, but about being smart about what we change and where.
Secondly, it helps explain why it’s actually a good idea to move all these low-level dependencies to the top level. Instead of creating classes deep inside our code, we can do that near the system’s entry point using things like factories, builders, or even configuration files to decide what to use.
For example, we could create this configuration file:
{
"mailbox_type": "ModernMailbox"
}
And read the classes to create like this:
class VintageMailbox; end
class ModernMailbox; end
class House
def initialize(mailbox)
@mailbox = mailbox
end
end
config = JSON.parse(File.read('config.json'))
mailbox_class_name = config["mailbox_type"]
mailbox = Object.const_get(mailbox_class_name).new
house = House.new(mailbox)
The complexity of deciding what specific classes we’re using has been moved to a single place at the top of our system. This means that, if we need to change the type of lock to be used in a house with a vintage mailbox, we just need to change our initial setup, while the rest of our code can be left unchanged.
Code extension
When we follow the Dependency Inversion principle, we can extend our codebase without changing the classes that implement our business logic. For example, imagine that we want to change class VintageMailbox
with ModernMailbox
. A simple approach would be to just do the changes in VintageMailbox
, or we could create a new class to represent a ModernMailbox
:
class VintageMailbox {}
class ModernMailbox {}
class House {
private mailbox = new ModernMailbox();
}
const h = new House();
Unfortunately, both alternatives go directly against the Open-Closed principle. If instead we use Dependency Inversion, we can create the new ModernMailbox
class and pass an instance to class House
.
interface Mailbox {}
class VintageMailbox implements Mailbox {}
class ModernMailbox implements Mailbox {}
class House {
constructor(private mailbox: Mailbox) {}
}
const mailbox: ModernMailbox = {};
const house = new House(mailbox);
In this example, we don’t need to change class House
if we want to use a different Mailbox
. By doing this, we are:
- Making code extensible without modification
- Avoiding fragile base classes
- Protecting existing behaviour when adding new features
Code reuse
Let’s review again the original implementation of the House
class.
class VintageMailbox {}
class House {
private mailbox = new VintageMailbox();
}
const h = new House();
Now, imagine that we want to create two houses, the first with a VintageMailbox
, and the second with a ModernMailbox
. One option that makes my eyes bleed is to duplicate our class House:
class VintageMailbox {}
class VintageHouse {
private mailbox = new VintageMailbox();
}
class ModernHouse {
private mailbox = new ModernMailbox();
}
const house1 = new VintageHouse();
const house2 = new ModernHouse();
Another option that is not any better is to add a variable to specify the type of Mailbox that we need, but this again violates the Open-Closed Principle:
class VintageMailbox {}
class ModernMailbox {}
class House {
private mailbox;
constructor(type: "vintage" | "modern") {
if (type === "modern") {
this.mailbox = new ModernMailbox();
} else {
this.mailbox = new VintageMailbox();
}
}
}
const house1 = new House("vintage");
const house2 = new House("modern");
When we depend on concrete classes, our code becomes too rigid and there is no way to extend it without either duplicating or changing code.
Instead, when we follow the Dependency Inversion Principle, we can create houses with different types of Mailbox
classes, even those we have not even thought of yet, without having to change how class House
works or uses the instances of class Mailbox
.
interface Mailbox {}
class VintageMailbox implements Mailbox {}
class ModernMailbox implements Mailbox {}
class FuturisticMailbox implements Mailbox {}
class House {
constructor(private mailbox: Mailbox) {}
}
const house1 = new House(new VintageMailbox());
const house2 = new House(new ModernMailbox());
const house3 = new House(new FuturisticMailbox());
Easier testing
Let’s consider our original example once more:
class VintageMailbox {}
class House {
private mailbox = new VintageMailbox();
}
const h = new House();
If we want to unit test class House
we’ll probably want to test it independently of class VintageMailbox
. Something like this:
jest.mock('./vintageMailbox', () => {
return {
VintageMailbox: jest.fn(), // mock constructor
};
});
test('House uses mocked VintageMailbox', () => {
(VintageMailbox as jest.Mock).mockImplementation(() => ({
deliver: jest.fn(),
}));
const house = new House();
expect(VintageMailbox).toHaveBeenCalled();
});
While working with Typescript I’ve sometimes felt like a wizard pulling up a trick when trying to mock something. And when it works, the code is not nice, and it’s usually left alone as just looking at it may break it.
If instead we use dependency inversion:
interface Mailbox {}
class VintageMailbox implements Mailbox {}
class House {
constructor(private mailbox: Mailbox) {}
}
const mailbox: VintageMailbox = new VintageMailbox();
const house = new House(mailbox);
We can just create a simple testing class with the logic we need, without relying on mocks.
class TestingMailbox implements Mailbox {}
test('House uses mocked VintageMailbox', () => {
const mailbox = new TestingMailbox()
const house = new House(mailbox);
expect(mailbox).toHaveBeenCalled();
});
When I write unit tests in Ruby using Rspec I prefer to use doubles instead of creating testing classes, as they are quite powerful and easy to create. And then, I just pass the double to the class being tested:
RSpec.describe House do
it 'uses a mocked mailbox' do
mailbox = double('VintageMailbox')
house = House.new(mailbox)
expect(house).to be_a(House)
end
end
As a result, when following the Dependency Inversion Principle, unit testing becomes extremely easy and produces cleaner test code.
Final thoughts
In this article, we saw what the Dependency Inversion Principle is and how to set it up, which will hopefully remove any worries about it being too complex. We also looked at the real benefits it brings: simpler code, easier testing, and better flexibility.
However, it’s important to be mindful of the context we’re working in, and not rush to change everything just to follow the principle. Like with anything in software, context matters. This is a great tool to have in our arsenal, but we should avoid premature optimisations and think carefully about when it actually makes sense to apply it.
Happy coding!
José Miguel
Share if you find this content useful.
Follow me on LinkedIn to be notified of new articles.