Dependency Injection & Inversion Explained | Node.js w/ TypeScript

Last updated Jan 9th, 2022
Dependency Injection and Depedency Inversion are two related but commonly misused terms in software development. In this article, we explore both types of DI and how you can use it to write testable code.

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.

repos/userRepo.ts
/**
 * @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...

controllers/userController.ts
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.

The UserController class depends directly on the UserRepo class.

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.

controllers/userController.ts
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.

This dependency relationship still holds true.

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.

controllers/userRepo.spec.ts
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.

repos/userRepo.ts
/**
 * @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.

controllers/userController.ts
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

repos/mocks/mockUserRepo.ts
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.

controllers/userRepo.spec.ts
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:

  1. Create a container (that will hold all of your app dependencies)
  2. Make that dependency known to the container (specify that it is injectable)
  3. 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!



View more in Software Design