In favor of dependency injection

Two common ways to structure the internal dependency tree of a Node app are to 1) direct import and 2) dependency injection. While slightly more costly in the short-term, dependency injection provides valuable returns on maintainability in the long-run.

Examples to establish a baseline for comparison

I will describe the two approaches two using code examples. The examples will be of a rudimentary backend Node app having three layers:
  • HTTP router
  • data access
  • data connection

Direct import

With direct import, the instances of functionality are created in separate files, and the dependency tree is wired together via import or require.
// app.ts import { router } from './router'; router.listen(process.env.PORT);
// router.ts import express from 'express'; import { getUser } from './data-access'; const router = express(); router.get('/user/:id', async (req, res) => { const data = await getUser(req.params.id); res.json({ data }); }); export { router };
// data-access.ts import { db } from './data-connection'; export function getUser(id: string) { return db.from('users').select('*').where({ id }).first(); }
// data-connection.ts import { knex } from 'knex'; export const db = knex({ client: 'pg', migrations: { directory: `${__dirname}/migrations` }, connection: { user: process.env.DB_USER, password: process.env.DB_PASSWORD, host: process.env.DB_HOST, database: process.env.DB_DATABASE, port: parseInt(process.env.DB_PORT), } });

Dependency injection

With dependency injection, the instances of functionality are created and wired together in or near the entrypoint file, where inner layers are instantiated first and passed as dependencies to outer layers.
// app.ts import { makeDataAccess } from './router'; import { makeRouter } from './router'; // database connection const db = knex({ client: 'pg', migrations: { directory: `${__dirname}/migrations` }, connection: { user: process.env.DB_USER, password: process.env.DB_PASSWORD, host: process.env.DB_HOST, database: process.env.DB_DATABASE, port: parseInt(process.env.DB_PORT), } }); const dataAccess = makeDataAccess(db); const router = makeRouter(dataAccess); router.listen(process.env.PORT);
// router.ts import express from 'express'; export { DataAccess } from './data-access'; export function makeRouter(dataAccess: DataAccess) { const router = express(); router.get('/user/:id', async (req, res) => { const data = await dataAccess.getUser(req.params.id); res.json({ data }); }); return router; }
// data-access.ts import { Knex } from 'knex'; export function makeDataAccess(db: Knex) { return { getUser: (id: string) => { return this.db.from('users').select('*').where({ id }); } } }

Analysis

Initialization and dependency-tree composition are inherent to both approaches, the difference being how and where. With direct imports, they occur statically at author-time across many files; with dependency injection, they occur dynamically at run-time in a single file.
With dynamic composition,
  • suppose you want to move a feature into a shareable npm package. With direct imports, the feature is coupled to its dependencies and can become a banana that brings the whole jungle. With dependency injection, the feature can be decoupled from its dependencies by converting constructor parameter types from classes into interfaces.
  • the decouple-ability is what makes constructor-based mocking simple in both concept and practice
  • with dependency injection, implementation variants can be swapped or conditionally provided. With direct imports, conditionality requires branching that will litter the scope of either the implementation file or consumer file.
Composing the tree in a single file gives an upfront bird's eye view of what each layer needs from the environment and how the layers are wired together.
// entrypoint.js const dbConn = makeDbConn(env.db); const migrator = makeMigrator(dbConn); const persistence = makePersistenceLayer(dbConn); const cache = env.isDev ? makeLocalMemoryCache() : makeGcpMemoryStore(env.redis); const businessLayer = makeBusinessLayer(persistence, cache); const router = makeRouter(businessLayer); router.listen(env.port);
With direct imports, that information is dispersed and buried.
// entrypoint.js import router from './router'; import env from './env'; router.listen(env.port);
Cascading environment variables from a single place achieves consistency in how request-scoped and non-request-scoped parameters originate and flow. Request-scoped parameters originate at an endpoint/command handler/listener and cascade into the business logic tree. Non-request-scoped parameters (i.e. env vars and configs) originate at the app entrypoint and cascade into the initialization tree. Both sets of parameters are essentially determinants of runtime behavior, so them having a similar consumption mechanism feels intuitively correct to me.
The dependency injection approach is paradigm around abstractions, whereas direct import is around concretions. In a scenario where we chose one way and wanted to switch to the other, migrating from abstractions to concretions would be easier.
Dependency injection via constructors is the basis of inversion-of-control containers and frameworks, which are pretty standard in other statically typed languages. To name a few, Java Spring, C# .NET, PHP Symfony and Laravel, Golang Gokit (not an IoC container but is designed around constructor injection).
When writing tests, passing functions through a dependency tree allows mock objects to be passed in via the constructor, as opposed to via jest which provides at least two ways to do it, .mock and .spyOn, each having their own nuances and gotchas. And it makes it natural to source env vars up-front in the app's entrypoint. Sourcing env vars in many places makes things hard to debug
One downside of dependency injection via class constructors is having to prevent circular dependencies between classes. I have found that it is easier to maintain uni-directional dependency flow among free-floating functions than classes+methods. When two pieces of logic have an affinity toward one another, the choices are to either create a uni-directional dependency that might not feel correct, merge the two into one big piece of logic, or create a third to hold the intersecting logic. With functions, this feels routine, but with classes it does feel like extra bookkeeping.