Dependency Injection & Inversion Explained | Node.js w/ TypeScript
This topic is taken from Solid Book - The Software Architecture & Design Handbook w/ TypeScript + Node.js. Check it out if you like this post.
Translated by readers to: Brazilian Portuguese
One of the first things we learn in programming is to decompose large problems into smaller parts. That divide-and-conquer approach can help us to assign tasks to others, reduce anxiety by focusing on one thing at a time, and improve modularity of our designs.
But there comes a time when things are ready to be hooked up.
That's where most developers go about things the wrong way.
Most developers that haven't yet learned about the solid principles or software composition, and proceed to write tightly couple modules and classes that shouldn't be coupled, resulting in code that's hard to change and hard to test.
In this article, we're going to learn about:
- Components & software composition
- How NOT to hook up components
- How and why to inject dependencies using Dependency Injection
- How to apply Dependency Inversion and write testable code
- Considerations using Inversion of Control containers
Terminology
Let's make sure that we understand the terminology on wiring up dependencies before we continue.
Components
I'm going to use the term component a lot. That term might strike a chord with React.js or Angular developers, but it can be used beyond the scope of web, Angular, or React.
A component is simply a part of an application. It's any group of software that's intended to be a part of a larger system.
The idea is to break a large application up into several modular components that can be independently developed and assembled.
The more you learn about software, the more you realize that good software design is all about composition of components.
Failure to get this right leads to clumpy code that can't be tested.
Dependency Injection
Eventually, we'll need to hook components up somehow. Let's look at a trivial (and non-ideal) way that we might hook two components up together.
In the following example, we want to hook up a UserController
so that it can retrieve all the User[]
s from a UserRepo
(repository) when someone makes an HTTP GET request to /api/users
.
/**
* @class UserRepo
* @desc Responsible for pulling users from persistence.
**/
export class UserRepo {
constructor () {}
getUsers (): Promise<User[]> {
// Use Sequelize or TypeORM to retrieve the users from
// a database.
}
}
And the controller...
import { UserRepo } from '../repos' // Bad
/**
* @class UserController
* @desc Responsible for handling API requests for the
* /user route.
**/
class UserController {
private userRepo: UserRepo;
constructor () {
this.userRepo = new UserRepo(); // Also bad, read on for why
}
async handleGetUsers (req, res): Promise<void> {
const users = await this.userRepo.getUsers();
return res.status(200).json({ users });
}
}
In the example, we connected a UserRepo
directly to a UserController
by referencing the name of the UserRepo
class from within the UserController
class.
This isn't ideal. When we do that, we create a source code dependency.
Source code dependency: When the current component (class, module, etc) relies on at least one other component in order to be compiled. Source code depdendencies should be limited.
The problem is that everytime that we want to spin up a UserController
, we need to make sure that the UserRepo
is also within reach so that the code can compile.
When might you want to spin up an isolated UserController
?
During testing.
It's a common practice during testing to mock or fake dependencies of the current module under test in order to isolate and test different behaviors.
Notice how we're a) importing the concrete UserRepo
class into the file and b) creating an instance of it from within the UserController
constructor?
That renders this code untestable. Or at least, if UserRepo
was connected to a real live running database, we'd have to bring the entire database connection with us to run our tests, making them very slow...
Dependency Injection is a technique that can improve the testability of our code.
It works by passing in (usually via constructor) the dependencies that your module needs to operate.
If we change the way we inject the UserRepo
from UserController
, we can improve it slightly.
import { UserRepo } from '../repos' // Still bad
/**
* @class UserController
* @desc Responsible for handling API requests for the
* /user route.
**/
class UserController {
private userRepo: UserRepo;
constructor (userRepo: UserRepo) { // Better, inject via constructor
this.userRepo = userRepo;
}
async handleGetUsers (req, res): Promise<void> {
const users = await this.userRepo.getUsers();
return res.status(200).json({ users });
}
}
Even though we're using dependency injection, there's still a problem.
UserController
still relies on UserRepo
directly.
Even still, if we wanted to mock out our UserRepo
that connects to a real SQL database for a mock in-memory repository, it's not currently possible.
UserController
needs a UserRepo
, specifically.
let userController: UserController;
beforeEach(() => {
userController = new UserController(
new UserRepo() // Slows down tests, needs a db running
)
});
So.. what do we do?
Introducing the Dependency Inversion Principle!
Dependency Inversion
Dependency Inversion is a technique that allows us to decouple components from one another. Check this out.
What direction does the flow of dependencies go in right now?
From left to right. The UserController
relies on the UserRepo
.
OK. Ready?
Watch what happens when we slap an interface in between the two components make UserRepo
implement an IUserRepo
interface, and then point the UserController
to refer to that instead of the UserRepo
concrete class.
/**
* @interface IUserRepo
* @desc Responsible for pulling users from persistence.
**/
export interface IUserRepo { // Exported
getUsers (): Promise<User[]>
}
class UserRepo implements IUserRepo { // Not exported
constructor () {}
getUsers (): Promise<User[]> {
...
}
}
And update the controller to refer to the IUserRepo
interface instead of the UserRepo
concrete class.
import { IUserRepo } from '../repos' // Good!
/**
* @class UserController
* @desc Responsible for handling API requests for the
* /user route.
**/
class UserController {
private userRepo: IUserRepo; // like here
constructor (userRepo: IUserRepo) { // and here
this.userRepo = userRepo;
}
async handleGetUsers (req, res): Promise<void> {
const users = await this.userRepo.getUsers();
return res.status(200).json({ users });
}
}
Now look at direction of the flow of dependencies.
You see what we just did? By changing all of the references from concrete classes to interfaces, we've just flipped the dependency graph and created an architectural boundary inbetween the two components.
Design principle: Program against interfaces, not implementations.
Maybe you're not as excited about this as I am. Let me show you why this is so great.
And if you like this article so far, you might like my book, "Solid - The Software Architecture & Design Handbook w/ TypeScript + Node.js". You'll learn how to write testable, flexible, and maintainable code using principles (like this one) that I think all professional people in software should know about. Check it out.
Remember when I said that we wanted to be able to run tests on the UserController
without having to pass in a UserRepo
, solely because it would make the tests slow(UserRepo
needs a db connection to run)?
Well, now we can write a MockUserRepo
which implements IUserRepo
and all the methods on the interface, and instead of using a class that relies on a slow db connection, use a class that contains an internal array of User[]
s (much quicker! ⚡).
That's what we'll pass that into the UserController
instead.
Using a MockUserRepo
to mock out our UserController
import { IUserRepo } from '../repos';
class MockUserRepo implements IUserRepo {
private users: User[] = [];
constructor () {}
async getUsers (): Promise<User[]> {
return this.users;
}
}
Tip: Adding "async" to a method auto-wraps it in a Promise, making it easy to fake asynchronous activity.
We can write a test using a testing framework like Jest.
import { MockUserRepo } from '../repos/mock/mockUserRepo';
let userController: UserController;
const mockResponse = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
beforeEach(() => {
userController = new UserController(
new MockUserRepo() // Speedy! And valid since it inherits IUserRepo.
)
});
test ("Should 200 with an empty array of users", async () => {
let res = mockResponse();
await userController.handleGetUsers(null, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ users: [] });
})
Congrats. You (more or less) just learned how write testable code!.
The primary wins of DI
Not only does this decoupling make your code testable, but it improves the following characteristics of your code:
- Testability: We can substitute expensive to infrastructure components for mock ones during testing.
- Substitutability: If we program against an interface, we enable a plugin architecture adhering to the Liskov Substitution Principle, which makes it incredibly easy for us to swap out valid plugins, and program against code that doesn't yet exist. Because the interface defines the shape of the dependency, all we need to do to substitute the current dependency is create a new one that adheres to the contract defined by the interface. See this article to dive deeper on that.
- Flexibility: Adhering to the Open Closed Principle, a system should be open for extension but closed for modification. That means if we want to extend the system, we need only create a new plugin in order to extend the current behavior.
- Delegation: Inversion of Control is the phenomenon we observe when we delegate behavior to be implemented by someone else, but provide the hooks/plugins/callbacks to do so. We design the current component to invert control to another one. Lots of web frameworks are built on this principle.
Inversion of Control & IoC Containers
Applications get much larger than just two components.
Not only do we need to ensure we're referring to interfaces and NOT concrete implementations, but we also need to handle the process of manually injecting instances of dependencies at runtime.
If your app is relatively small or you've got a style guide for hooking up dependencies on your team, you could do this manually.
If you've got a huge app and you don't have a plan for how you'll accomplish dependency injection within in your app, it has potential to get out of hand.
It's for that reason that Inversion of Control (IoC) Containers exist.
They work by requiring you to:
- Create a container (that will hold all of your app dependencies)
- Make that dependency known to the container (specify that it is injectable)
- Resolve the depdendencies that you need by asking the container to inject them
Some of the more popular ones for JavaScript/TypeScript are Awilix and InversifyJS.
Personally, I'm not a huge fan of them and the additional infrastructure-specific framework logic that they scatter all across my codebase.
If you're like me and you're not into container life, I have my own style guide for injecting dependencies that I talk about in solidbook.io. I'm also working on some video content, so stay tuned!
Inversion of Control: Traditional control flow for a program is when the program only does what we tell it to do (today). Inversion of control flow happens when we develop frameworks or only refer to plugin architecture with areas of code that can be hooked into. In these areas, we might not know (today) how we want it to be used, or we wish to enable developers to add additional functionality. That means that every lifecycle hook in React.js or Angular is a good example of Inversion of Control in practice. IoC is also often explained by the "Hollywood Design Principle": Don't call us, we'll call you.
Stay in touch!
Join 20000+ value-creating Software Essentialists getting actionable advice on how to master what matters each week. 🖖
View more in Software Design