Dependency Injection (DI) is a technique in which a software component receives other components (its dependencies) without it to have the responsibility to resolve and instantiate them.
This can favour loose coupling between the components, and separation of concerns: the client has only to manage its own functionalities.
Introduction example
Consider the following pseudo code:
// bankAccount.service.js import { createConnectionPool } from './db.js' import { createUsersService } from './user.js' import { createAuthorizationService } from './authorization.js' export const createBankAccountService = ({conf}) => { const db = createConnectionPool({conf}); const Users = createUsersService({db}); const Authorization = createAuthorizationService({Users}); return { async createBankAccount({userId, bankAccount}) { const authorization = Authorization.authorizeBankAccount({userId}); if (!authorization) { throw new Error('can not authorize bank account'); } return db.query(createBankAccountQuery({bankAccount})); } }; };
You’ll note that the bank account service:
- Has to create its dependencies, which may imply creating transitive dependencies: ie the dependencies of its own dependencies. In particular, the bank account service creates a user service it will not use by itself.
- Has to import the factories of its dependencies (the functions used to create those services). It means the bank account service needs to know where to find these factories. As a side effect, the bank account service is coupled to specific implementations of its dependencies rather than using abstract interfaces.
- Although not a big problem, this instanciation code also clutters the service logic.
In comparison, consider the following snippet:
export const createBankAccountService = ({Authorization, db}) => { return { async createBankAccount({userId, bankAccount}) { const authorization = Authorization.authorizeBankAccount({userId}); if (!authorization) { throw new Error('can not authorize bank account'); } return db.query(createBankAccountQuery({bankAccount})); } }; };
The bank account service does not create any dependency but got them injected, with the following direct benefits:
- The transitive dependency
Users
has disappeared - The service does not depend on any specific implementation of
Authorization
anddb
as long as what get injected implement the same interfaces - The code is less cluttered and more readable
- The service itself focuses on its own functionalities and nothing else.
The Injector
Obviously, the code related to the resolution and instantiation of the various dependencies still exists somewhere, but in a component dedicated to that purpose. Let’s build this component: the injector.
import {createConnectionPool} from '../db.js'; import {createUsersService} from '../users.js' // etc const injectablesManifest = { db:createConnectionPool, Users: createUsersService }; const injectables = inject(injectablesManifest);
The inject
function should be able to resolve the whole dependency tree based on the injectables manifest specifications: if Users
depends on db
, then const { Users } = inject(injectablesManifest)
should give you an instance of Users
with its dependencies resolved.
The manifest
The manifest (or registry) here is a sort of map whose keys are the injectables tokens (the values you will use to lookup for any particular injectable), and whose values are factory functions (functions used to create an instance of the injectable). These factories all have the same signature (Where deps
is a map of any injectable):
const factory = (deps) => injectable
By using factory functions we can implement pretty much any instanciation pattern:
- If we always want the same instance of a given injectable (singleton), we can just wrap the factory into some sort of memoize function:
const factoryA = (deps) => { return { methodA() {}, methodB() {} } } const memoize = (fn) => { let value; return (deps) => { if(value){ return value; } return value = fn(deps); } } const getMySingletonA = memoize(factoryA) // which complies with import {test} from 'zora'; test('should always return the same instance of the injectable', (t)=>{ t.ok(getMySingletonA() === getMySingletonA()) });
- If we have a constructor based instantiation (with classes), we only need to wrap the constructor into a function
class ServiceA { constructor(deps){ } } const serviceAFactory = (deps) => new ServiceA(deps)
- value function: wraps any constant value into a function
const myDep = 'foo'; const valueFn = (val) => (whatever) => val const myDepFactory = valueFn(myDep); // wich complies with import {test} from 'zora'; test('should return the constant', (t)=>{ t.eq(myDepFactory(), 'foo') });
This gives more flexibility over a class system with constructor based instantiation as you can see in many popular frameworks nowadays.
How you build or register the injectables factories into the manifest is out of the scope of this article and will depend on the context (going from a plugin system to a set of sophisticated class annotations, etc)
Solving the transitive dependencies problem with meta programming
As mentioned in the introduction: if you want a service A, but it depends on a service B which in turn depends on a service C, it is difficult to build all the services in the dependency chain. Based on the manifest (a flat structure), we can solve the problem of dependency resolution through meta-programming.
Considering that there is no circular dependency, you can model the relationships between the services with a Directed Acyclic Graph (DAG) where the nodes are the services and the vertices represent the “depends on” relationship.
We can leverage property descriptors to build a magic object which evaluates lazily the service factories as they are required based on the manifest.
Assuming the following small helper which maps the values of an object to a set of other values
const mapValues = (mapFn) => (object) => Object.fromEntries( Object .entries(object) .map(([prop, value])=> [prop, mapFn(value)]) ) // ex const upperCase = mapValues(value=>value.toUpperCase()); upperCase({ foo: 'bar' }); > // { foo: 'BAR' }
We can build the property descriptors of our magic object
export const inject = (injectablesFactoryMap) => { const injectables = {}; // (1) the object which will hold the injectables const propertyDescriptors = mapValues(createPropertyDescriptor); // (2) we make this object a meta object which will instantiate an injectable whenever a token will be looked up Object.defineProperties( injectables, propertyDescriptors({ ...injectablesFactoryMap }) ); return injectables; function createPropertyDescriptor(factory) { return { get() { return factory(injectables); // "injectables" are injected in every factory so the dependency graph is magically resolved }, enumerable: true }; } };
- We need first to create an empty object which will hold the injectables and will get in every factory when the latter is called.
- This meta object keys are the injectables’ tokens and the property getters are overwritten in such a way that when you look up for a specific injectable (calling the getter), the factory is called. Interestingly as the factories themselves use the
injectables
object they receive, the relevant getters are called as well so that the whole graph is lazily resolved for the factory initially called.
This solves few of the problems:
- We automatically resolve the transitive dependencies.
- You only import the various factories once, when you build the manifest, making all the components agnostic of where their dependencies live
import { test } from 'zora'; import { inject } from './di.js'; // depends on "B" const createServiceA = ({ B }) => { return { foo() { return B.foo(); }, }; }; // depends on "C" const createServiceB = ({ C }) => { return { foo() { return C.foo(); }, }; }; const createServiceC = () => { return { foo() { return 'bar'; }, }; }; // injectable manifest: map a token to a factory const manifest = { A: createServiceA, B: createServiceB, C: createServiceC, }; test(`resolve transitive dependencies`, (t) => { const { A } = inject(manifest); // (1) here we call the getter for A t.eq(A.foo(), 'bar'); });
The statement (1) creates the dependency graph: when you destructure the meta-object, the getter of A is called, which leads to a cascade of calls to the getter of B, then to that of C.
💡 Note we can change the implementation of the mapValues
function to also map properties whose key is a Symbol. This type of properties can operate as “secret tokens” to make some injectables only injectable to whomever knows or has access to the Symbol: you can’t lookup for a property whose key is a Symbol if you don’t have a reference of that Symbol.
test(`Use symbol for tokens`, (t) => { const sym = Symbol('foo'); const manifest = { [sym]: valueFn('foo'), canAccessSymbol: ({ [sym]: foo }) => foo, cantAccessSymbol: ({ [Symbol('foo')/* trying to lookup for the symbol */]: foo, }) => foo, }; const { canAccessSymbol, cantAccessSymbol } =createInjector(manifest)(); t.eq(canAccessSymbol, 'foo'); t.eq(cantAccessSymbol, undefined, 'could not resolve the symbol'); });
Going further: make sure we can swap injectable implementation depending on the context
The current implementation is fine for all the dependency graphs you already know prior to the execution of the program. However, in practice you will likely have “late binding”: you will only know the implementation of a given injectable to use at run time, based on the execution context. In some cases, you might also want to overwrite the default implementations provided. Remember, when dealing with dependency injection you must think with interfaces and abstract types !
For example, in a web server you could bind your injectables to the user related to the current incoming request (and as so, avoiding security concerns on who accesses the resources). You can also use a specific injectable implementation depending on the profile of the user, their location, etc.
To solve theses use cases we are going to make our injector a function which can define/overwrite some specific factories to allow late dependency binding.
export const createInjector = (injectableFactoryMap) => { // (1) The injector is now wrapped within a function return function inject(lateDeps = {}) { const injectables = {}; const propertyDescriptors = mapValues(createPropertyDescriptor); Object.defineProperties( injectables, propertyDescriptors({ ...injectableFactoryMap, ...lateDeps, // (2) you can overwrite already defined factories or create late bindings }) ); return injectables; function createPropertyDescriptor(factory) { const actualFactory = typeof factory === 'function' ? factory : valueFn(factory); // (3) Syntactic sugar return { get() { return actualFactory(injectables); }, enumerable: true, }; } }; };
The main differences are:
- We wrap the injector within a function so we can invoke the dependency resolution multiple times
- The manifest can be extended or overwritten later on
- The manifest now accepts any value which is not a factory function: the injector wraps it automatically (this is just for convenience)
This implementation should comply with the following specifications:
import { test } from 'zora'; import {createInjector} from './di.js'; const defaultUser = { name: 'John' }; const inject =createInjector({ Greeter: ({ user }) => ({ greet() { return `Hello ${user.name}`; }, }), user: defaultUser, }); test(`Use default injectable instances as provided by the manifest`, (t) => { const { Greeter } = inject(); t.eq(Greeter.greet(), 'Hello John'); t.ok( Greeter !== inject().Greeter, 'should return a different instance on every invocation' ); }); test(`Overwrite dependencies at invocation time`, (t) => { t.eq(inject().Greeter.greet(), 'Hello John'); t.eq( inject({ user: { name: 'Bob', }, }).Greeter.greet(), 'Hello Bob' ); t.eq( inject({ user: { name: 'Raymond', }, }).Greeter.greet(), 'Hello Raymond' ); });
Recursivity: Injecting the injector function
In some cases, a injectable may need to inject its dependency graph into one of its own functions. We could make that injectable build its own manifest and injector, but that would mean mixing several responsibilities and leaking implementation details of the injector/registration system: ie going backward compared to what we have built so far.
Instead we can simply make sure an injector get injected itself and therefore allows recursive calls.
Let’s see the point with a typical use case:
You have a client to access a database such node-pg for postgresql. For efficiency, your client will actually be a pool of clients which abstracts away the internals.
db.query(`SELECT * FROM table`)
When you make such a call you don’t really care whether the client comes from a pool, is already connected, etc. You just want to declare the intention: you want to perform a database query. However if your intention is to run a SQL transaction the client must be the same, holding a session and eventually wrapping the rollback on error etc.
import pg from 'pg'; import {createClient} from './client.js'; import { createClient} from './db.js'; // simple wrapper around the raw driver export const createConnectionPool= (conf) => { const pool = new pg.Pool(conf); return { // ... some db interface // ... async withinTransaction(fn) { const client = await pool.connect(); try { await client.query('BEGIN'); const result = await fn({ db:createClient({ client }) }); await client.query('COMMIT'); return result; } catch (e) { await client.query('ROLLBACK'); throw e; } finally { client.release(); } }, }; };
You’ll use with a client function as so:
await db.withinTransaction(function clientFn({ db }) { // transaction bound code using the local "db" client db.query(query1); db.query(query2); //etc });
This is fine for low level calls, but in practice you’ll use higher level services which depend on an abstract database client. Therefore, you will have to make sure to bind them to the transaction bound db
client instance, which imply calling the factories from the clientFn
… and you are now facing the caveats we saw at the beginning of this article.
import {createUsersService} from './user.js' import {createAuthorizationService} from './authorization.js' await db.withinTransaction(async function clientFn({ db }) { // bind services to the transaction session const Users = createUsersService({db}); const Authorization = createAuthorizationService({Users}); // etc });
With the following implementation of the injector
export constcreateInjector= (injectableFactoryMap) => // (1) the returned function is named "inject" function inject(args = {}) { // ... Object.defineProperties( injectables, propertyDescriptors({ ...injectableFactoryMap, inject: valueFn(inject), // (2) we inject the "inject" function ! ...args, }) ); // ... };
You are now able to recursively call the inject function as it is added to the dependency graph (2):
const bindToInjectables = (db) => // (1) ({ inject }) => ({ ...db, // (2) overwrite the implementation of the db "withinTransaction" method withinTransaction: (fn) => // (3) delagate to the actual implementation ... db.withinTransaction(({ db }) => // (4) but calling the client function with the injectables bound to the transaction client fn( inject({ db: bindToInjectables(db), }) ) ), }); // ... const inject = createInjector(manifest); // ... // const db = getting db from somewhere const injectables = inject({ db: bindToInjectables(db) })
The function bindToInjectables
takes a db
object and returns a new factory to pass to a manifest or to the inject function for late binding:
- As any registered factory it gets the injectables… including the inject function itself
- we overwrite the provided
db
instance withinTransaction
delegates the call- calling the client function with all the dependencies where
db
refers here to the transaction bound client.
import { test } from 'zora'; import { createInjector } from './lib/di/injector.js'; const createDB = (depth = 0) => { return { depth, withinTransaction: (fn) => { return fn({ db: createDB(depth + 1) }); }, }; }; const serviceFactory = ({ db }) => { return { foo() { return db.depth; }, }; }; const inject = createInjector({ A: serviceFactory, }); test('withinTransaction should scope the clientFn to a given db instance', (t) => { const { A, db } = inject({ db: createDB() }); t.eq(A.foo(), 0, 'should have the default depth'); t.eq( db.withinTransaction(({ db }) => { return serviceFactory({ db }).foo() }), 1, 'child db client' ); }); test('Injector binds clientFn to the local db context', (t) => { const bindToInjectables = (db) => ({ inject }) => { return { ...db, withinTransaction: (fn) => db.withinTransaction(({ db }) => { return fn( inject({ db: bindToInjectables(db), }) ); }), }; }; const { A, db } = inject({ db: bindToInjectables(createDB()) }); t.eq(A.foo(), 0, 'should have the default depth'); t.eq( // A is injected into the transaction function db.withinTransaction(({ A }) => { return A.foo(); }), 1, 'local A is bound to child client (depth 1)' ); });
Icing on the cake: improve the dev experience
We have already improved the developer experience by giving the ability to inject anything (not only a factory function). But the current component can fail if there is a missing dependency giving a quite unhelpful error:
import {createInjector} from './lib/di/injector.js'; const injector =createInjector({}); const { A } = injector(); A.foo();// BOOM: Cannot read properties of undefined (reading 'foo')
By using a proxy we can trap the getters’ calls and detect if a token does not exist in the dependency graph, giving a more helpful feedback to the developer.
export const createInjector= (injectableFactoryMap) => function inject(args = {}) { const target = {}; // (1) injectables is now a proxy const injectables = new Proxy(target, { get(target, prop, receiver) { if (!(prop in target)) { throw new Error(`could not resolve factory '${prop.toString()}'`); } return Reflect.get(target, prop, receiver); }, }); const propertyDescriptors = mapValues(createPropertyDescriptor); Object.defineProperties( target, propertyDescriptors({ ...injectableFactoryMap, inject: valueFn(inject), ...args, }) ); return injectables; function createPropertyDescriptor(factory) { const actualFactory = typeof factory === 'function' ? factory : valueFn(factory); return { get() { return actualFactory(injectables); }, enumerable: true, }; } };
In (1) we trap all the calls to any getter to first check if the injectable has been defined. If not we throw an error: the same code as above will print: could not resolve factory 'A'
There is a caveat though: you can’t use default values when declaring your factories. The following test will not pass:
test(`A factory with default values should resolve to those values`, (t) => { try { const { a } = createInjector({ a: ({ b = 'foo' }) => b, }); t.eq(a, 'foo', 'resolve default values'); } catch (err) { t.fail('should not have thrown'); } });
Conclusion
In this essay, we have seen what dependency injection is and how it can help to build better software. We implemented a fairly robust and flexible DI container(injector) in a few lines of code thanks to the meta-programming features of Javascript. This component does not depend on any framework. You can see an example of a web server (based on Fastify) using this DI component in this repository. Details are given in the readme.