Once you have created and maintained a couple of applications, you get to appreciate if a change is done quickly.
This success is likely to not come by accident.
You don't need to leave the success of writing maintainable software to chance. There is an easy pattern you can follow to make changes a breeze, without needing to worry to break something. This patterns name is Dependency Injection. Or in short: DI.
I am going to show you how.
What needs dependency injection? Well, everything that is outside the function that we want to access. That makes it sound very tedious, but read on to see the benefits.
1. Single Source Dependency
/**
*
* @param {Pick<Math, 'random'>} Math
*/
const random = Math => Math.random()
// in production
random(Math) // anything between 0 - 1
// in tests, etc
random({ random: () => 4 }) // 4
The type annotation via @params
or TypeScript is optional, but it enables you to know what actually is inside that argument.
We can directly control the outcome from outside the function. We only program against a known interface. We know what properties the parameter need in order to work.
Lets check a more real-world example.
const fs = require('fs/promises')
const readFile = (fs, filename) => fs.readFile(filename)
readFile(fs, 'some-file.txt') // some content
DI enables us to extend the behavior by wrapping the readFile
function.
const loggedFs = {
readFile: filename => {
console.log(`reading now file "${filename}"`)
return fs.readFile(filename)
}
}
readFile(loggedFs, 'some-file.txt')
// reading now file "some-file.txt"
// some content
If you have hard-wired the fs.readFile
function in every place of your application, the only chance to add logging is to put the logger in front of every single read statement.
If you have used the readFile
function instead, you have now enabled yourself to change the behavior of all calls at the same time. If you don't wish to use logging at some place, you can still explicitly opt out of the dependency by using the original one.
Now this is what I call efficiency!
This works perfectly fine with other aspects too:
- caching
- validating
- transforming the argument
- transforming the return value
- message queue publishing
- intercepting in general
- testing
- ...
In unit tests, you need to leave out the side effects (reading from or writing to a file, database, network, console, etc.) to make sure the test is repeatable without much preparation. DI enables us to do exactly that, by replacing the evil parts with mocks or spies. I prefer sinon for that task.
2. Multiple Dependencies as Parameters
Now doing this for multiple dependency quickly becomes cumbersome.
For the sake of completeness, here is how you could do it:
const report = async (log, saveToDb, Date, message) => {
log(`${new Date()}: ${message}`)
await saveToDb(new Date(), message)
}
It is now troublesome to distinguish what exactly is part of the original function signature and what is a dependency. Which is why I prefer to pass one single object.
3. Multiple Dependencies as Object
You can use destructuring to directly get just the dependencies you need.
This is fine for functions that do not need to pass their dependencies further down the line. Otherwise, you also have to remember to pick the dependencies.
/**
* @param {object} context
* @param {object} context.db
* @param {function} context.log
* @param {number} id
*/
const readUserFromDbById = async ({ db, log }, id) => {
const user = await db.where({ id }).fetch()
log(`user fetched: ${user.name}`)
return user
}
Or simply use a good name for the dependencies that are easy to pass to other functions.
/**
* @param {object} context
* @param {object} context.db
* @param {function} context.log
* @param {number} id
*/
const readUserFromDbById = async (context, id) => {
const user = await context.db.where({ id }).fetch()
context.log(`user fetched: ${user.name}`)
return user
}
If you find another parameter name more fitting, you can also use one of these:
- ctx
- deps
- dependencies
- options
- settings
- opts
4. Delayed Execution
Lets see how we can make using DI less painful. We can prepare functions that are already filled with dependencies. The higher level code (abstraction) doesn't have to concern itself what dependencies are actually needed for every single call. Especially if they are used over and over again.
const readUserFromDbById = context => async id => { ... }
// usage
const readUser = readUserFromDbById({ db: {}, log: console.log }) // function
await readUser(1)
If however, you have situations where you are not certain whether you can attach both the dependencies and the argument/s, you can make it more flexible using curry
.
const R = require('ramda')
const readUserFromDbById = R.curry((context, id) => { ... })
// usage
readUserFromDbById(ctx)(3)
// or
readUserFromDbById(ctx, 3)
Alternatively, if you don't want to have curry all over the place in your code, you can choose to opt in where you need to have the delayed execution.
const readUserFromDbById = async (context, id) => { ... }
// usage
readUserFromDbByIdWithContext = R.partial(readUserFromDbById, context)
readUserFromDbByIdWithContext(3)
5. Module Dependencies: Closure
const appsFactory = ctx => {
const list = () => ctx.fs.readJSON('apps.json')
const get = async id => (await list())[id]
return {
list,
get,
}
}
This is by far the most simple way to use dependency injection on a list of functions sharing the same dependencies and semantic value. We only have to pass the dependencies once for the entire module.
There are some drawbacks in flexibility though.
We cannot supply an alternative for list
inside the get
function.
We cannot extract a single function - we always have to retrieve the entire factory.
Read on to learn how to mitigate this problem.
6. Module Dependencies: Function Level
If you have an entire module that more or less share the same dependencies, you can create a factory function to provide all the dependencies and options.
const fs = require('fs-extra')
const R = require('ramda')
const list = ctx => async () => ctx.fs.readJSON(ctx.filePath)
const get = ctx => async id => (await list(ctx)())[id]
get({ fs })(1) // 2 (given that the file outputs [1,2,3])
get({ fs: { readJSON: () => [2,3,4] } })(0) // 2
// functions that require dependencies
const dependencyFns = { list, get }
// return a list of all functions but with the dependencies filled in
// I use Ramda here, because it allows me to map over objects too
const appsFactory = ctx => R.map(d => d(ctx), dependencyFns)
module.exports = {
...dependencyFns,
default: appsFactory({ fs, filePath: './apps.json' }),
factory: appsFactory,
}
// Usage
const apps = require('./apps').default
apps.list() // [1,2,3]
apps.get(0) // [1,2,3][0] -> 1
This way, you are free to consume the module in three ways:
- Only one function, supplying the dependencies on your own (good for testing)
- The entire module, supplying the dependencies on your own (good for total control)
- The entire module, but with the recommended dependencies filled in already (libraries)
You can still make distinctions between configurable behavior (settings, such as ctx.filePath
) and functions needed (ctx.fs
)
You can mix and match styles of what you have already learnt in the previous examples.
7. Module Dependencies: Dependency Level
The disadvantage of the method above, is that we need to supply the entire dependency chain.
We cannot simply exchange the list
function in get
, we need instead to define how fs.readJSON
behaves.
By also making the modules functions a dependency, we can also replace those. This is especially useful if you want to ignore for example half of the functions behavior and don't want to supply a no-op function for all the in-between steps.
const list = ctx => async () => ctx.fs.readJSON(ctx.filePath)
const get = ctx => async id => (await ctx.list(ctx)())[id]
Lets take a stab at wiring it together in our factory function.
const appsFactory = ctx => R.map(d => d({ ...dependencyFns, ...ctx }), dependencyFns)
appsFactory({list: () => () => [2, 3, 4]}).get(0) // [2,3,4][0] -> 2
get({ list: () => () => [1, 2, 3] })(0) // [1,2,3][0] -> 1
Note, that here you now have to also provide the function returning a function. This is probably the only drawback of this solution.
Conclusion
These are the 7 ways how you can do dependency injection in JavaScript.
If you know me from my previous articles, you know if I appreciate one thing, it is simplicity.
Now Dependency Injection is everything but simple.
If anything, it adds a layer of extra code. However, this will quickly pay off, once you enable yourself to easily exchange modules or extend interfaces without needing to worry about making existing code not work anymore. If you use TypeScript, you can even retrieve warnings if dependencies don't match the expected function signature.
It is taking some education to appreciate DI and its benefits. Take the time to learn more. Below I linked the resources that helped me to wrap my head around this concept when I started out to learn about dependency injection.
DI isn’t an end-goal—it’s a means to an end. DI enables loose coupling, and loose coupling makes code more maintainable.
Mark Seeberg in Dependency Injection - The Book
Happy coding!
Going beyond
If you are writing purely functional software, you should check out the Reader pattern.
Resources
Thanks to these people for their insightful articles on this topic.
- https://jrsinclair.com/articles/2018/how-to-deal-with-dirty-side-effects-in-your-pure-functional-javascript/
- https://blog.ploeh.dk/2010/04/07/DependencyInjectionisLooseCoupling/
- https://freecontent.manning.com/dependency-injection-writing-maintainable-loosely-coupled-code
- https://mostly-adequate.gitbooks.io/mostly-adequate-guide/content/ch03.html#oh-to-be-pure-again
- https://mostly-adequate.gitbooks.io/mostly-adequate-guide/content/ch05.html
Do you control your code, or does your code control you?
Get access to Martin's personal Telegram group to receive daily advice how to create, architect and maintain software applications. Every day, you'll get a piece of advice you can directly apply to your real world applications and:
- Write maintainable software with confidence
- Get total control of your code
- Optimize your output
- Tackle advanced development challenges
- … plus, plenty of resources, guidance, and stories.
( It's free! )