Dependency Injection in nodejs

Magnus Tovslid
7 min readAug 10, 2017

--

After spending a fair bit of time in the nodejs world it’s become apparent to me that dependency injection (DI) is not used as much as it should be. In my opinion, DI is the most important pattern for decoupling and developer happiness. In this article, I want to show you why.

What is dependency injection?

DI is about receiving things instead of taking them. It can come in many forms:

// Constructor injection
class Car{
constructor(engine){
this.engine = engine;
}
}

// Setter injection
class Car{
constructor(){

}
setEngine(engine){
this.engine = engine;
}
}

// Taking an input argument and binding a function
function createCarWithEngine(engine){
// create a car with the engine
}

let createCar = createCarWithEngine.bind(null, engine);

Notice how we can ignore everything to do with the engine and simply focus on the car.

The key take-away is that we don’t care where our dependencies are, how they are made, or even what they are. This has enormous benefits to code decoupling, reusability, testing, and readability.

But let’s look at a more real-world example…

Case: The User Repository

Imagine we have a repository of users, or put simply: we have a database with a table of users, and we would like to do stuff to that table. In this case, we need some components:

  • Some database config
  • A database connection
  • The user repository

From the perspective of the UserRepo, we can go about this a couple of different ways. We can ask for a ready-made db connection, we can create the db connection ourselves, or we can simply receive the db connection and let someone else deal with the details.

Let’s look at the alternatives in more detail:

Asking for the DB connection

If we decide to ask for a db connection, it might look something like this:

const db = require('./db');

class UserRepo{
getUsers(){
return db.query('SELECT * FROM users');
}
}

This doesn’t look too bad.

But what exactly is happening inside that ./db file? Well, since we get a database connection it has to somehow create this connection. What follows is that once we require the UserRepo-file, the database connection will also be created. It seems we have lost control over when the database is connected.

Furthermore, what happens if the location of the ./db file changes? We now have to update all references to that file.

And what happens if we want to have a separate db-connection for our inevitable TodoRepo? Do we create another file called db2?

And what if we want to test this? We can monkey-patch the require-statement, but that’s a pretty nasty hack that actually modifies the code-under-test before testing it.

And what happens if we decide to reuse this code in a library or something? We’ve hardcoded our dependency to ./db, so we can’t use the code as-is.

Creating the DB ourselves

A slight variation on asking for the database connection is creating it ourselves. It might look something like this:

const createDb = require('./createDb');
const dbConfig = require('./dbConfig');

const db = createDb(dbConfig);
// OR
// const db = createDb(process.env.CONNECTION_STRING);

class UserRepo{
getUsers(){
return db.query('SELECT * FROM users');
}
}

In this case we‘re in control of the instantiation, so we can create the connection anyway we want. We can also easily have multiple connections if we like. However, if we want to reuse the connection, we need some sort of pooling or caching in place.

Still, we have many of the same problems as before (testing is even worse), and the UserRepo file is getting more stuffed by the minute.

And now another problem becomes apparent as well. Where do we get the dbConfig from? Like ./db, it comes from a file we’ve hardcoded in, so it has all the same problems that ./db has.

What about environment variables?

We could try to help the situation by getting the db config from process.env, making the configuration a bit more flexible. However, this also leads to a bunch of problems. Now UserRepo needs to come up with a naming scheme for the env-variables which doesn’t clash with other schemes, and that is definitely not the job of the UserRepo. It’s easier to give things general names like DB_NAME, but then we lose some of the flexibility. Using env-variables like this is also a form of hidden dependency. When we instantiate UserRepo we cannot immediately see that there are env-variables we are supposed to set.

Also, imagine code using process.env inside an npm-package. Obviously this is crazy, but a surprisingly large number of packages on npm do in fact depend on env-variables. This is because they depend on the debug-module, which itself depends on multiple env-variables. The main issue with this is that library authors are encouraged to not provide any interface for injecting custom debug-loggers. The interface has become the env-variables. This means that if we want to send the logs somewhere the debug-module doesn’t support, we can’t (or we have to monkey patch). The other issue is that the env-variable naming scheme has been reserved. If another library comes along, it cannot use the same variables since the names would collide. This is not an unlikely situation, since libraries are forked all the time.

Receiving the DB (aka: Dependency Injection)

Finally, we do things using dependency injection. We use constructor injection, because that’s usually the simplest. Here’s what it looks like:

class UserRepo{
constructor(db){
this._db = db;
}

getUsers(){
return this._db.query('SELECT * FROM users');
}
}

Notice how the UserRepo doesn’t know anything about how the db is setup. It doesn’t know how many instances there are, it doesn’t know about config, it doesn’t care where the module is located. Obviously, the db still needs to be created, and it still needs config. Let’s fix that.

// Database.js
class Database{
constructor(config){
this.config = config;
}

connect(){
// create database
}
query(){ }
}

// config.js
const config = {
DB_CONFIG_MYSQL: {
server: process.env.DB_SERVER,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PW
},
DB_CONFIG_POSTGRES: {
// ...
}
};

// index.js
const config = require('./config');
const Database = require('./Database');
const UserRepo = require('./UserRepo');

let db = new Database(config.DB_CONFIG_MYSQL);
db.connect();let userRepo = new UserRepo(db);

Looking at the example above, we have made the UserRepo decoupled from the database, and the database decoupled from its config.

You may notice that we’re still using env-variables for the config. This is OK because we have pushed their usage up to the highest level of the application, and made the consumer (the database) unaware of them. This makes it so we don’t have to come up with any naming scheme. The database can just use simple names like “server”, “user”, and “password”. There are also no hidden dependencies because its the app itself that has the dependencies on the env-variables. Another minor point is that the env-variables will never find their way into a library. We can now just take database.js and use it as a library without any modification, and without any dependency on env-variables or other things.

Other things we get is:

  • Control over when to connect the database
  • Control over where to put the files. The require-statements only need to be updated in one place.
  • The ability to easily create multiple db connections (or share one connection)
  • The ability to test the code (by passing in a mock db connection)

Also, can you imagine what it is like to write the UserRepo now? You can start writing the UserRepo without having written the Database yet. You’re now free to come up with any API you want for the Database, and write that API later. If there already is such an API, you can still do whatever you want since you’re not depending directly on anything. If there is a better API, you can just write an adapter. But importantly: The UserRepo does not need to know about this adapter.

What’s the catch?

Although dependency injection is a great tool, it is of course not the best tool for the job in all situations. The big downside to DI is the same as with most software patterns; it abstracts things. When we abstract away something, we also make it a bit harder to understand. In our previous case, we can no longer immediately see that the db is created in the ./db file, and must find this information elsewhere. So for abstractions such dependency injection to be worth it, they have to offset the cost of the abstraction by a large amount. In the case of dependency injection, it’s almost always worth it, even in small apps. Just the way it simplifies testing is worth it alone.

When to not use dependency injection

This is mostly a subjective decision. Oftentimes it can be OK to require things directly if they are very tightly coupled. For example, you could use a User in a UserRepo. However, be aware that you often end up wanting things decoupled later. Imagine that a User also needs some default config. In this case it might be better to have the UserRepo take in a UserFactory that creates Users. This enables us to inject config into User objects without UserRepo knowing about it.

It can also be OK to skip DI if there is zero chance your thing needs configuration or replacement. For example, I would probably not inject npm packages like lodash, but I would inject an sql-connection from the mysql package.

What about intellisense?

It is true that intellisense will be gone if you use certain forms for DI. But if you use constructor injection together with ES6 classes, you can easily fix this with a type annotation:

class UserRepo{
/**
*
@param {Database} db
*/
constructor(db){
this._db = db;
}
}

What about IOC containers?

Dependency injection is all about pushing the responsibility for constructing things up in the hierarchy. If we keep on doing this, after a while most of the construction logic will end up at the very top of the hierarchy, somewhere near index.js. This usually generates somewhat of a mess, and it is here IOC containers come in.

In the next article I will explain IOC containers, but not to worry, I won’t ask you to install any packages, frameworks, or xml-files.

--

--