This library implements lifetimes, scopes and mocking for pure dependency injection.
Before reading, it's highly recommended that you familiarize yourself with the concepts of inversion of control (IoC) and dependency injection (DI), as well as DI techniques.
If you need a container to build your application, or you are satisfied with classic pure dependency injection, you should definitely consider other solutions, or not use a framework at all.
This library is an attempt to provide full-featured dependency injection without containers.
We can implement lifetime using static initializations together with factory functions that create instances on demand. However, this can introduce inconsistency into a composition code.
This library solves this problem by allowing to resolve instances once using the same factory technique.
Often in your application you may need to resolve instances separately for different "scopes" of a program, be it a request, a transaction or a worker thread. This behavior can be achieved by correctly distributing transient resolutions, but at scale the complexity of this approach will only increase.
This library solves this problem by introducing into factories (hereinafter referred to as providers) an ability to work with a map of providers to their instances, which serves as a scope.
Testability is an important part of every application. IoC handles this very well, but to perform a unit test we still need to resolve modules. To ensure testing without side effects, developers often use mocking - replacing implementations with others with the same behavior. We can rebuild modules manually for each unit test or group of unit tests, but at scale this approach can introduce a lot of extra manual work without any significant benefit.
This library solves this problem by allowing you to use factories that have been defined for a main application build. It's enough to create a map of mock providers to providers with the same interface, and pass it to a provider call to replace implementations in its dependencies.
You can use any package manager.
npm add atomic-di
npx jsr add @ensi/di
The library provides functions that create providers with behavior typical of singletons, transients, and scopeds.
Transient providers are created using transient
function:
const getThing = transient(() => createThing())
Transient providers are no different from regular factories except for additional logic required for scopes and mocks to work correctly. This logic is also present in other two functions, you can read about it here.
Singleton providers are created using singleton
function:
const getA = singleton(() => createA()) const getB = transient((c) => createB(getA(c)))
In this case, calling getA
will always result in a same instance, and a passed factory will only be called once:
getA() === getA() == getB().A === getB().A
You may have noticed that the getB
provider factory uses a certain c
argument. This is a context that can optionally be passed when calling a provider, you can read about it here.
Scoped providers are created using scoped
function:
const getThing = scoped(() => createThing())
When calling this provider without passing a scope to a resolution context, it will act as a singleton, resulting in a same instance:
getThing() === getThing()
To get resolutions within a scope, we need to pass it to a provider call in a resolution context object:
const scope = createScope() getThing({ scope }) === getThing({ scope })
You can read more about scopes here.
Each provider can accept a resolution context object. This is an object with optional scope
and mocks
fields that defines how an instance will be resolved.
In all provider factories that have dependencies, this context must be passed into all calls to other providers to ensure it is propagated up a call chain.
const getA = singleton(() => createA()) const getB = scoped(() => createB(getA())) const getC = scoped(() => createC(getB()))
In this case, a context will not propagate beyond getC
and other providers will not know about a current scope and mocks, and getB
will return an instance that is not related to any scopes.
const getA = singleton(() => createA()) const getB = scoped((c) => createB(getA(c))) const getC = scoped((c) => createC(getB(c)))
In this case, getC
will propagate a context, and getB
and getA
will be aware of a current mocks and scopes, resolving instances correctly.
More details on how provider behaves depending on a passed context can be found in sections about mocking and scoping.
To replace implementations inside factories, we can use a mock map. To create one, we can use createMockMap
function:
const mockMap = createMockMap()
To register a mock, you need to set
an entry with an original provider in a key and its mock in a value:
mockMap.set(getDatabase, getMockDatabase)
Once all mocks have been registered, this map can be passed to a provider call. If a provider finds a mock in a resolution context, it checks whether it is among the keys, and in that case returns a mock call instead of itself.
const getA = transient(() => 1) const getB = transient((c) => getA(c) + 1) getB() === 2
const getBee = transient((c) => getA(c) + "bee") const mocks = createMockMap().set(getB, getBee) getB({ mocks }) === "1bee"
const getA = transient(() => 1) const getB = transient((c) => getA(c) + 1) const getC = transient((c) => getB(c) + 1) getC() === 3
const getSea = transient((c) => getB(c) + "sea") const mocks = createMockMap().set(getC, getSea) getC({ mocks }) === "2sea"
In this library, a scope is a map of providers to their resolutions. To create one, you can use createScope
function:
const scope = createScope()
It is passed to a scoped provider call or to a call of a provider that has the scoped provider among its transitive dependencies.
const getThing = scoped(() => createThing())
const thing1 = getThing() const thing2 = getThing() thing1 === thing2
const thing1 = getThing({ scope }) const thing2 = getThing({ scope }) const thingFallback = getThing() thing1 === thing2 !== thingFallback
const getScopedDependency = scoped(() => ...) const getThing = transitive((c) => createThing(getScopedDependency(c)) )
const thing1 = getThing() const thing2 = getThing() thing1.scopedDependency === thing2.scopedDependency
const thing1 = getThing({ scope }) const thing2 = getThing({ scope }) const thingWithFallback = getThing() thing1.scopedDependency === thing2.scopedDependency !== thingWithFallback
It often happens that you need to resolve instances of a large number of entities, in our case providers, with a same context. Fortunately, the library provides functions for this.
To resolve instances of a list of providers, you can use resolveList
function, which takes a list of providers and a common resolution context. If at least one provider in the passed list of providers returns a Promise
, the function will return a Promise
of a list of awaited resolutions.
const getA = scoped(() => createA()) const getB = scoped(() => createB()) const getC = scoped(() => createC()) const scope = createScope() const resolutions = resolveList( [getA, getB, getC], { scope } )
resolutions == [ getA({ scope }), getB({ scope }), getC({ scope }) ]
const getA = scoped(() => createA()) const getB = scoped(async () => await createB()) const getC = scoped(() => createC()) const scope = createScope() const resolutions = await resolveList( [getA, getB, getC], { scope } )
resolutions == [ getA({ scope }), await getB({ scope }), getC({ scope }) ]
To resolve instances of a provider map, or an object with string keys and providers in a values, you can use resolveMap
function, which takes a provider map and a common resolution context. If at least one provider in the values of the passed provider map returns a Promise
, the function will return a Promise
of a map of awaited resolutions.
const getA = scoped(() => createA()) const getB = scoped(() => createB()) const getC = scoped(() => createC()) const scope = createScope() const resolutions = resolveMap( { a: getA, b: getB, c: getC }, { scope } )
resolutions == { a: getA({ scope }), b: getB({ scope }), c: getC({ scope }) }
const getA = scoped(() => createA()) const getB = scoped(async () => await createB()) const getC = scoped(() => createC()) const scope = createScope() const resolutions = await resolveMap( { a: getA, b: getB, c: getC }, { scope } )
resolutions == { a: getA({ scope }), b: await getB({ scope }), c: getC({ scope }) }
transient
function transient<T>(resolver: Resolver<T>): Provider<T>
resolver
: A function that returns a value of a particular type with a resolution context being passed to it.Creates a transient provider that will resolve a new instance on each call.
const getThing = transient(() => createThing()) getThing() !== getThing()
singleton
function singleton<T>(resolver: Resolver<T>): Provider<T>
resolver
: A function that returns a value of a particular type with a resolution context being passed to it.Creates a singleton provider that will resolve an instance once and return it on every call.
const getThing = singleton(() => createThing()) getThing() === getThing()
scoped
function scoped<T>(resolver: Resolver<T>): Provider<T>
resolver
: A function that returns a value of a particular type with a resolution context being passed to it.Creates a scoped provider that will take its resolution from a passed scope or create a new one and save it if there is none. If no scope is passed, it will act as a singleton
const getThing = scoped(() => createThing()) getThing() === getThing()
const getThing = scoped(() => createThing()) const scope = createScope() getThing({ scope }) === getThing({ scope }) !== getThing()
createMockMap
function createMockMap(): MockMap
Creates a Map
of providers to providers of the samep type which is then passed to a provider call in a resolution context object in order to replace providers with their mocks.
const mocks = createMockMap() .set(getConfig, getTestConfig) getThing({ mocks })
createScope
function createScope(): Scope
Creates a Map
of providers to their instances that is then passed to a provider call in a resolution context object to resolve instances of scoped providers within it.
const requestScope = createScope() app.use(() => { const db = getDb({ scope: requestScope }) // ... })
resolveList
function resolveList<const Providers extends ProviderList>( providers: Providers, context?: ResolutionContext ): AwaitValuesInCollection< InferProviderCollectionResolutions<Provider> >
providers
: A list of providers.context?
: A resolution context.Calls every provider in a list with a provided resolution context and returns a list of resolutions. Returns a Promise
of a list of awaited resolutions if there's at least one Promise
in the resolution.
Only sync providers:
const getA = scoped(() => createA()) const getB = scoped(() => createB()) const getC = scoped(() => createC()) const scope = createScope() const resolutions = resolveList( [getA, getB, getC], { scope } )
resolutions == [ getA({ scope }), getB({ scope }), getC({ scope }) ]
Some provider is async:
const getA = scoped(() => createA()) const getB = scoped(async () => await createB()) const getC = scoped(() => createC()) const scope = createScope() const resolutions = await resolveList( [getA, getB, getC], { scope } )
resolutions == [ getA({ scope }), await getB({ scope }), getC({ scope }) ]
resolveMap
function resolveMap<const Providers extends ProviderRecord>( providers: Providers, context?: ResolutionContext ): AwaitValuesInCollection< InferProviderCollectionResolutions<Provider> >
providers
: A map of providers.context?
: A resolution context.Calls every provider in a map with a provided resolution context and returns a map with identical keys but with resolutions in values instead. Returns a Promise
of a map of awaited resolutions if there's at least one Promise
in the resolutions.
Only sync providers:
const getA = scoped(() => createA()) const getB = scoped(() => createB()) const getC = scoped(() => createC()) const scope = createScope() const resolutions = resolveMap( { a: getA, b: getB, c: getC }, { scope } )
resolutions == { a: getA({ scope }), b: getB({ scope }), c: getC({ scope }) }
Some provider is async:
const getA = scoped(() => createA()) const getB = scoped(async () => await createB()) const getC = scoped(() => createC()) const scope = createScope() const resolutions = await resolveMap( { a: getA, b: getB, c: getC }, { scope } )
resolutions == { a: getA({ scope }), b: await getB({ scope }), c: getC({ scope }) }
Resolver
type Resolver<T> = (context?: ResolutionContext) => T
A function that returns a value of a particular type with a resolution context being passed to it.
Provider
type Provider<T> = (context?: ResolutionContext) => T
A function that resolves an instance or a Promise
of a particular type based on a resolution context passed to it.
ResolutionContext
type ResolutionContext = { scope?: Scope; mocks?: MockMap; }
A context used by providers to resolve instances based on current scope and mocks.
MockMap
type MockMap = Omit<Map<Resolver<any>, Resolver<any>>, "set" | "get"> & { set<T>(provider: Resolver<T>, mock: Resolver<T>): MockMap; get<T>(provider: Resolver<T>): Resolver<T> | undefined; };
set
: Sets a mock for a provider.
provider
: The original provider.mock
: The mock provider.get
: Retrieves a mock of a provider. Returns undefined if there's none.
provider
: The provider.A Map
of providers to providers of the same type which is then passed to a provider call in a resolution context object in order to replace providers with their mocks.
Scope
type Scope = Map<Resolver<any>, any>
A Map
of providers to their instances that is then passed to a provider call in a resolution context object to resolve instances of scoped providers within it.
This is free and open source project licensed under the MIT License. You could help its development by contributing via pull requests or submitting an issue.
Add Package
deno add jsr:@ensi/di
Import symbol
import * as di from "@ensi/di";
---- OR ----
Import directly with a jsr specifier
import * as di from "jsr:@ensi/di";
Add Package
npx jsr add @ensi/di
Import symbol
import * as di from "@ensi/di";
Add Package
yarn dlx jsr add @ensi/di
Import symbol
import * as di from "@ensi/di";
Add Package
pnpm dlx jsr add @ensi/di
Import symbol
import * as di from "@ensi/di";
Add Package
bunx jsr add @ensi/di
Import symbol
import * as di from "@ensi/di";