Writing Better Apps: Dependency Injection
Dependency Injection (DI) is a pattern where the "things" a component (function, class) depend on are passed into the component instead of the component trying to instantiate (or resolve it) itself. This practice tends to make code a lot more robust by reducing a component's scope. The DI pattern also simplifies the testing process by allowing developers to replace standard implementations of dependencies with mocks or test doubles.
For example, imagine a function that does not use dependency injection:
import config from './config'
import knex from 'knex'
async function saveUserPreferences(userPrefs: UserPreferences): Promise<void> {
// Ok, so how do I get the DB? Maybe I should just instantiate it!
const db = knex(config.db)
await db('user_preferences')
.update(userPrefs)
.where({ userId: userPrefs.userId })
db.close()
}
The obvious flaw in this example is that I have to instantiate the db
in every function that wants to make the call. Alternatively, if you're going to use a database connection pool, it becomes impossible since every repository function has its own instance. Another issue is that we have to manage the database client's lifecycle (by calling close
at the end).
Of course, we could have magically imported the database from another module:
import db from './db'
export async function saveUserPreferences(userPrefs: UserPreferences): Promise<void> {
await db('user_preferences')
.update(userPrefs)
.where({ userId: userPrefs.userId })
}
Now think about how we test this code. What if I want to verify that the database is called with the correct signature?
import { saveUserPreferences } from './userPreferencesRepository'
describe('saveUserPreferences', () => {
it('should update preferences for the record with matching userId', async () => {
// hmmm... how do I do this? Do I need to run the DB locally?
// Do I execute the update and then call the database to see if the change was successful?
});
})
In the Jest unit test framework, you can override a module using jest.mock('./db')
. You would then create a matching module in a relative folder called __mocks__
(e.g., __mocks__/db.ts
) with your test implementation. However, this is not true for every language, and frankly, this pattern is ugly (sorry, Jest devs).
Inversion of Control
A much simpler alternative is to invert the component responsible for controlling dependencies to the caller (Inversion of Control). Instead of saveUserPreferences
resolving or instantiating db
on its own, what if we passed the db
to the function?
import{ Knex } from 'knex'
export async function saveUserPreferences(db: Knex, userPrefs: UserPreferences): Promise<void> {
await db('user_preferences')
.update(userPrefs)
.where({ userId: userPrefs.userId })
}
Unit testing becomes a lot easier:
import{ Knex } from 'knex'
import { saveUserPreferences } from './userPreferencesRepository'
describe('saveUserPreferences', () => {
let db: (table: string) => Knex
let ctx: Knex
beforeEach(() => {
// We are going to use a trick and partially implement the db.
// Also, I probably have the type signatures of Knex incorrect.
const ctx = {
update: jest.fn(() => ctx),
where: jest.fn() => Promise.resolve()>,
} as unknown as Knex
db = jest.fn(() => ctx)
})
it('should update preferences for the record with matching userId', async () => {
const prefs = {
userId: 42,
email: 'foo@bar.com',
phone: '555-867-5309',
}
await saveUserPreferences(db, prefs)
expect(db).toHaveBeenCalledWith('user_preferences')
expect(ctx.update).toHaveBeenCalledWith(prefs)
expect(ctx,where).toHaveBeenCalledWith({ userId: prefs.userId })
});
})
Of course, passing around db
to every repository function will become a massive pain in the butt as the code base grows. More importantly, it makes callers conscious that saveUserPreferences
uses some RDBS they probably shouldn't know about. In fact, our domain code should be relying on abstractions and not have any reference to Knex
at all! How can we use DI but hide implementation details from the caller?
Using Factories
The answer is that you need a little bit of application code to hide the injection details. A straightforward way of doing this is to wrap your saveUserPreferences
in a factory function and take advantage of closure scope, allowing the function to reference db
without the caller knowing:
import{ Knex } from 'knex'
export function saveUserPreferencesFactory(db: Knex) {
return async (userPrefs: UserPreferences): Promise<void> => {
await db('user_preferences')
.update(userPrefs)
.where({ userId: userPrefs.userId })
}
}
Now, when your application starts, you can build an instance of the saveUserPreferences
function and pass around the built instance, which has a reference to the db
in the upper scope:
const saveUserPreferences = saveUserPreferencesFactory(db);
await saveUserPreferences({
userId: 42,
email: 'foo@bar.com',
phone: '555-867-5309',
})
Structuring Dependency Injected Applications
Now that you've seen the general implementation of DI on a component level, how do you structure an application around this concept?
Generally, apps that use DI follow an inverted hierarchical instantiation model where components with the least dependencies are built first and injected into components that need only the newly built dependencies and on and on until you have a fully configured root component. We commonly refer to this as an "object graph." This is arguably the most cumbersome part of DI.
An example of doing this manually looks like this:
// lots of imports...
export default function getDeps(config: Config): Deps {
const db = knex(config.db)
const saveUserPreferences = saveUserPreferencesFactory(db)
const getUserPreferences = getUserPreferencesFactory(db)
// ...
const userService = new UserService(
config.max,
saveUserPreferences,
getUserPreferences
)
// ...
const createUserRoute = createUserRoute(userService);
// ...
return {
createUserRoute,
// ... Other top-level components used by a web server or something.
}
}
This approach to DI is valid but very manual (and frankly annoying). The developer is responsible for resolving the correct order of dependency creation. More importantly, all components are instantiated immediately. Imagine the code base is an API server, but you wanted to write a couple of administrative command-line utilities using services/helper functions in the codebase. You probably don't want to start database connections if you don't need to. You certainly don't want an instance of the webserver starting. So how do we accomplish this when the DI pattern has us creating our object graph on initialization?
The answer is that we want lazy loading. This means a component is only created when it is needed. If the requested component depends on other components, those components are also constructed. Using our previous example, if we wanted an instance of UserService
, we would also need saveUserPreferences
and getUserPreferences
, both of which need db
.
So how do we write the lazy loading code?
Dependency Injection Frameworks
The short answer is, we don't!
Lazy loading dependencies and resolving an object graph is a non-trivial programming task. It would be silly to try to write that functionality every time we want to build an application. Instead, we can leverage various frameworks built by our community of developers that will do this for us.
In the JavaScript/TypeScript world, there are various Dependency Injection frameworks. My personal favorite is a library called Awilix.
Let's rewrite our getDeps
function using Awilix.
import { NameAndRegistrationPair, asFunction, asClass, asValue } from 'awilix'
export default function getDeps(config: Config): NameAndRegistrationPair<Deps> {
return {
// "asValue" means "this is a constant".
config: asValue(config),
// "cradle" is a magic proxy that will auto resolve your dependency
// by the "field" name in this NameAndRegistrationPair
db: asFunction((cradle) => knex(cradle.config.db)),
// You can even deconstruct the cradle object.
saveUserPreferences: asFunction(({ db }) => saveUserPreferencesFactory(db)),
// And if you use the pattern of having your factory function accept
// an object of dependencies as it's first parameter, you can simply:
getUserPreferences: asFunction(getUserPreferencesFactory),
// Injection into the constructor of an object.
userService: asClass(UserService),
// There's actually a better way to use Awilix in libraries like
// Express. We will look at those in another post.
createUserRoute: asFunction(createUserRouteFactory),
}
}
Now when you want to resolve a component, you pull it out of the Dependency Injection context:
import { createContainer } from 'awilix'
import getDeps from './getDeps'
// assuming you have constructed the config
const deps = getDeps(config)
const container = createContainer().register(deps)
// To resolve a dependency, you can do it directly:
const userService = container.resolve('userService')
// Alternatively, Awilix makes that cool dependency proxy available
// to callers:
const { saveUserPreferences, getUserPreferences } = container.cradle
Conclusion
Using Dependency Injection will make your applications better. The pattern applies to applications of all sizes. Smaller applications probably don't need features like lazy-loading and dynamic dependency resolution and can avoid the complexity of adding a DI framework. Larger applications tend to have large and complex dependency hierarchies. Leveraging a decent DI framework can simplify the structure and maintenance of the application.
In the next post, I will demonstrate some of the magical things you can do with a decent DI framework. I hope this will encourage you to think about how you can write better apps.
Parting Thoughts
Despite DI as an essential pattern for code bases of any size, some developers gripe about the practice. If you are new to programming and unsure, I am here to dispel any doubts. I cannot think of a single, respectable software Thought Leader that thinks the DI "pattern" is bad. When engineers complain about DI, they really mean the DI "framework" they are using.
Some people follow bad practices of directly instantiating components because they think they are practicing "KISS" (Keep It Simple Stupid). These are probably the same people you inherited that shitty legacy codebase from that is impossible to maintain and as fragile as a snowflake in June.
You might also be interested in these articles...
Stumbling my way through the great wastelands of enterprise software development.