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 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.
You can use any package manager.
npm add atomic-di
npx jsr add @ensi/di
The approach to dependency injection in this library is factories. It consists of a factory creating an instance of a certain type by calling other factories that resolve dependencies for it.
To implement lifetimes, scope, and mocking mechanisms, the library provides functions that create factories with functionality specific to a particular lifetime, such factories are called resolvers. They all have some functionality in common, but first let's look at the functions that create them.
The transient
function creates a basic resolver that does not contain any logic that controls a lifetime of a resolution. This means that this resolver will call a passed factory and return a new instance each time it is called.
const getRandom = transient(Math.random)
getRandom() !== getRandom()
The singleton
function creates a resolver that contains a logic specific to singletons. This means that a singleton resolver will only call a passed factory once, and will return a single instance created each time it is called.
const getRandom = singleton(Math.random)
getRandom() === getRandom()
The scoped
function creates a resolver that contains logic specific to scoped registrations, often supported by IoC containers. These resolvers operate on scope instances that are passed into a resolution context when called. They check whether their instance is in a scope, and depending on this, save a new instance or return an existing one within that scope. If a resolver is not passed a scope when called, it will behave as a singleton, simulating a global scope.
const getRandom = scoped(Math.random)
getRandom() === getRandom()
getRandom({ scope: myScope }) === getRandom({ scope: myScope })
getRandom() !== getRandom({ scope: myScope })
A detailed explanation of the scope mechanism and its use is described in this section.
Each resolver takes an optional resolution context. This is an object that can contain a scope and a mock map. Based on this context, resolvers determine how to resolve an instance.
In order for a resolution context to correctly influence a current resolution, it must be propagated up a resolver call chain so that each resolver is aware of a current context. Therefore, if a factory uses other resolvers, it must pass a resolution context it receives into each resolver call.
const getDependency = transient(() => createDependency())
const getEntity = transient(() =>
createEntity(getDependency())
)
const getDependency = transient(() => createDependency())
const getEntity = transient((c) =>
createEntity(getDependency(c)) // context is propagated
)
Mocking is a common mechanism in testing whereby some implementation being used is replaced with a replica to prevent side effects or reduce testing load.
This library implements this mechanism by adding logic to each resolver responsible for replacing itself (not a resolution) when its own mock is present in a resolution context. A definition of mocks in a resolution context is done by passing a mock map to this resolution context.
A mock map is an immutable object similar to Map
that implements an interface for registering and receiving mocks of some resolvers. To create one, you must use createMockMap
function.
const mocks = createMockMap()
To register a mock resolver, use mock
method, passing an original resolver and its mock. It will create a new mock map with this registration.
const mocks = createMockMap()
.mock(getDatabase, getDatabaseMock)
.mock(getLogger, getLoggerMock)
// ...
If you need to partially replace an implementation, i.e. replace some fields in a resolution, use mockPartially
method. Both original and mock resolver must return an object or a Promise
of an object.
const getDatabaseMock = singleton(() => ({
execute: (q) => console.log("db: executing", q)
}))
const mocks = createMockMap()
.mockPartially(getDatabase, getDatabaseMock)
To resolve an instance with mocks, you must pass a previously defined mock map to a resolution context when calling any resolver.
resolver({ mocks: myMockMap })
If resolver's direct or transitive dependencies or the resolver itself have their mock registered in a mock map, they will replace themselves with this mock, depending on a type of a mock. This behavior is clearly demonstrated in examples below.
const getDependency = transitive(() => "dependency")
const getEntity = transitive((c) => ({
dependency: getDependency(c)
}))
const getDependencyMock = transitive(() => "dependencyMock")
const mocks = createMockMap()
.mock(getDependency, getDependencyMock
getEntity({ mocks }) == {
dependency: "dependencyMock"
}
const getDependency = transitive(() => ({
value: "dependency",
origin: "getDependency"
}))
const getEntity = transitive((c) => ({
dependency: getDependency(c)
}))
const getDependencyMock = transitive(() => ({
value: "dependencyMock"
}))
const mocks = createMockMap()
.mockPartially(getDependency, getDependencyMock
getEntity({ mocks }) == {
dependency: {
value: "dependencyMock", // replaced
origin: "getDependency" // original value
}
}
Sometimes you need to create and save resolutions for different areas of your program, such as a request or a thread. Scopes solve this problem.
IoC containers implement this by defining copies of a container in different parts of a program. Within this library, a scope is simply a map of resolvers to their resolutions. This map is used by scoped resolvers described earlier.
There are two ways to create a scope:
- By calling
createScope
function.
const scope = createScope()
- By creating a
Map
with the correct type manually.
const scope = new Map<Resolver<any>, any>()
To get a scoped resolver resolution within a scope, a scoped resolver, or a resolver that has a scoped resolver in its direct or transitive dependencies, must be called with a scope passed to a resolution context.
const getEntity = scoped(() => createEntity())
const scope = createScope()
const scopeEntity = getEntity({ scope })
scope.get(getEntity) === scopeEntity
const getDependency = scoped(() => createDependency())
const getEntity = transient((c) => ({
dependency: getDependency(c)
}))
const scope = createScope()
const entity = getEntity({ scope })
scope.get(getDependency) === entity.dependency
Often you may need to get resolutions of a large number of resolvers within a single context at once. Doing this manually is inefficient, so the library provides functions specifically for this.
If you need to get a list of resolutions of different resolvers, you can use resolveList
function.
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 })
]
If one of passed resolvers returns a promise, the function will return a Promise
of a list of awaited resolutions.
const getA = scoped(() => createA())
const getB = scoped(async () => createB())
const getC = scoped(() => createC())
const scope = createScope()
const resolutions = await resolveList(
[getA, getB, getC],
{ scope }
)
resolutions == [
getA({ scope }),
await getB({ scope }),
getC({ scope })
]
If you need to get a map of resolutions of different resolvers, you can use resolveMap
function.
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 })
}
If one of passed resolvers returns Promise
, the function will return a Promise
of a map of awaited resolutions.
const getA = scoped(() => createA())
const getB = scoped(async () => createB())
const getC = scoped(() => createC())
const scope = createScope()
const resolutions = await resolveList(
{ a: getA, b: getB, c: getC },
{ scope }
)
resolutions == {
a: getA({ scope }),
b: await getB({ scope }),
c: getC({ scope })
}
function transient<T>(fn: ResolverFn<T>): Resolver<T>
resolver
: A function that takes a resolution context and returns a value of some type.
Creates a resolver that creates a new resolution on each call.
const getEntity = transient(() => createEntity())
getEntity() !== getEntity()
function singleton<T>(resolver: ResolverFn<T>): Resolver<T>
resolver
: A function that takes a resolution context and returns a value of some type.
Creates a resolver that creates a resolution once and return it on each call.
const getEntity = singleton(() => createEntity())
getEntity() === getEntity()
function scoped<T>(resolver: ResolverFn<T>): Resolver<T>
resolver
: A function that takes a resolution context and returns a value of some type.
Creates a resolver that takes its resolution from a scope or create a new one and save it if there is none. If no scope was passed in a resolution context, it will act as a singleton.
const getEntity = scoped(() => createEntity())
getEntity() === getEntity()
const getEntity = scoped(() => createEntity())
const scope = createScope()
getEntity({ scope }) === getEntity({ scope }) !== getEntity()
function createMockMap(): MockMap
Creates a mock map, an immutable map that registers and provides mocks. Is passed in a resolution context and used by resolvers to replace or partially replace themselves with a mock if one is defined.
const mocks = createMockMap()
.mock(getDependency, getDependencyMock)
.mockPartially(
getOtherDependency,
transient(() => ({ someField: "mock" }))
)
const entityWithMocks = getEntity({ mocks })
function createScope(): Scope
Creates a Map
of resolvers to their resolutions. Is passed in a resolution context and used by scoped resolvers to retrieve or save resolution within it.
const requestScope = createScope()
app.use(() => {
const db = getDb({ scope: requestScope })
})
function resolveList<const Resolvers extends ResolverList>(
resolvers: Resolvers,
context?: ResolutionContext
): AwaitValuesInCollection<
InferResolverCollectionResolutions<Resolvers>
>
resolvers
: A list of resolvers.context?
: A resolution context.
Calls every resolver 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 resolutions.
Only sync resolvers:
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 resolver 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 })
]
function resolveMap<const Resolvers extends ResolverRecord>(
resolvers: Resolvers,
context?: ResolutionContext
): AwaitValuesInCollection<
InferResolverCollectionResolutions<Resolvers>
>
resolvers
: A map of resolvers.context?
: A resolution context.
Calls every resolver 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 awaited resolutions if there's at least one Promise
in the resolutions.
Only sync resolvers:
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 resolver 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 })
}
type ResolverFn<T> = (context?: ResolutionContext) => T
A function that takes a resolution context and returns a value of some type.
type Resolver<T> = (context?: ResolutionContext) => T
A function that returns a value of some type based on a resolution context.
type ResolutionContext = {
scope?: Scope;
mocks?: MockMap;
}
A context used by resolvers that defines the behaviour of the resolver with the passed mocks and scope.
type MockMap = {
mock<T>(original: Resolver<T>, mock: Resolver<T>): MockMap;
mockPartially<T extends object>(
original: Resolver<T>,
mock: Resolver<PromiseAwarePartial<T>>,
): MockMap;
get<T>(original: Resolver<T>): Mock<T> | undefined;
};
mock
: Registers a mock for a resolver, creating a newMockMap
with this registration.original
: The original resolver.mock
: The mock resolver.
mockPartially
: Registers a partial mock for a resolver, creating a newMockMap
with this registration. In this case, the mock resolver's resoluton object will be merged with the original resolver's resolution object, overwriting certain fields.original
: The original resolver.mock
: The mock resolver.
get
: Returns a mock of a resolver orundefined
if one is not registered.original
: The original resolver.
Immutable map that registers and provides mocks. Is passed in a resolution context and used by resolvers to replace or partially replace themselves with a mock if one is defined.
type Scope = Map<Resolver<any>, any>
A Map
of resolvers to their resolutions. Is passed in a resolution context and used by scoped resolvers to retrieve or save resolution 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.