[NestJS Deep Dive] 03. InstanceLoader and Injector
Hello!
In the previous post, we looked at how the metadata for modules and dependent objects is registered in NestJS. In this post, I’m going to talk about InstanceLoader
and Injector
, which plays the core role in managing the lifecycle of dependency objects registered in the module.
NestJS Deep Dive Series
- NestFactory
- @Module and DynamicModule
- InstnaceLoader and Injector
Dependency Injection in NestJS
Dependency Injection
is a programming methodology that declaratively expresses the dependencies of instances and rest of the work such as creating instances delegated to IoC containers managed by the framework. The details of the dependency injection are outside the scope of this posting, so we will skip them for now.
When implementing dependency injection, it is very important to parse the dependencies of objects accurately and coordinate them in order. In NestJS, there are largely constructor-based
and property-based
methods. In the example below, the CatController
has CatService
and HttpClient
as its dependency. Therefore, in order to create an instance of the CatController
, you must first create an instance of the CatService
and HttpClient
, then inject them when you create an instance of the CatController
. In NestJS, InstanceLoader
and Injector
be responsible for coordinating these processes.
@Controller
class CatController {
// property-based
@Inject('HTTP_OPTIONS')
private readonly httpClient: HttpClient;
// constructor-based
constructor(private readonly catService: CatService) {}
}
InstanceLoader
InstanceLoader
, like its name, is a class that parses metadata from a module to create dependency objects (Provider
, Injectable
, Controller
). The role of InstanceLoader
can be largely divided into prototype creation and instance creation. InstanceLoader
first creates a prototype object for the dependency object, and then leverages it to create an instance of the dependency object. If you look at the internal implementation, you can see that most of the key features of InstanceLoader
depends on the Injector
which is the core internal class that handle most of dependency injection logics.
// packages/core/injector/instance-loader.ts
export class InstanceLoader {
...
// ========================================================
public async createInstancesOfDependencies(
modules: Map<string, Module> = this.container.getModules(),
) {
this.createPrototypes(modules);
await this.createInstances(modules);
}
// ========================================================
private createPrototypes(modules: Map<string, Module>) {
modules.forEach(moduleRef => {
this.createPrototypesOfProviders(moduleRef);
this.createPrototypesOfInjectables(moduleRef);
this.createPrototypesOfControllers(moduleRef);
});
}
private async createInstances(modules: Map<string, Module>) {
await Promise.all(
[...modules.values()].map(async moduleRef => {
await this.createInstancesOfProviders(moduleRef);
await this.createInstancesOfInjectables(moduleRef);
await this.createInstancesOfControllers(moduleRef);
const { name } = moduleRef.metatype;
this.isModuleWhitelisted(name) &&
this.logger.log(MODULE_INIT_MESSAGE`${name}`);
}),
);
}
private createPrototypesOfProviders(moduleRef: Module) {
const { providers } = moduleRef;
providers.forEach(wrapper =>
this.injector.loadPrototype<Injectable>(wrapper, providers), // call injector
);
}
private async createInstancesOfProviders(moduleRef: Module) {
const { providers } = moduleRef;
const wrappers = [...providers.values()];
await Promise.all(
wrappers.map(item => this.injector.loadProvider(item, moduleRef)), // call injector
);
}
}
InstanceWrapper<T>
Before we jump into the Injector
, let’s take a quick look at InstanceWrapper
. NestJS internally wraps and manages instances of all dependency objects in a class called InstanceWrapper
. InstanceWrapper
has a variety of metadata that a single dependency object needs in the dependency injection process, and is responsible for managing the lifecycle of an instance according to Context
and Scope
.
// packages/core/injector/instance-wrapper.ts
export class InstanceWrapper<T = any> {
public readonly name: any;
public readonly token: InstanceToken;
public readonly async?: boolean;
public readonly host?: Module;
public readonly isAlias: boolean = false;
public scope?: Scope = Scope.DEFAULT;
public metatype: Type<T> | Function;
public inject?: FactoryProvider['inject'];
public forwardRef?: boolean;
public durable?: boolean;
private readonly values = new WeakMap<ContextId, InstancePerContext<T>>();
private transientMap?:
| Map<string, WeakMap<ContextId, InstancePerContext<T>>>
| undefined;
private isTreeStatic: boolean | undefined;
private isTreeDurable: boolean | undefined;
private readonly [INSTANCE_METADATA_SYMBOL]: InstanceMetadataStore = {};
private readonly [INSTANCE_ID_SYMBOL]: string;
private static logger: LoggerService = new Logger(InstanceWrapper.name);
public createPrototype(contextId: ContextId) {
const host = this.getInstanceByContextId(contextId);
if (!this.isNewable() || host.isResolved) {
return;
}
return Object.create(this.metatype.prototype);
}
}
If you look closely at how dependency objects are registered in a module, you can find the presence of InstanceWrapper
. As we saw in the previous post, DependenciesScanner
parses the metadata of a module and registers the metadata of the dependency object that the module has in the Module
object. If you follow the relevant code, you will see that Module.addProvider()
is finally called to create an InstanceWrapper
object.
// packages/core/scanner.ts
export class DependenciesScanner {
public async scan(module: Type<any>) {
await this.registerCoreModule();
await this.scanForModules(module);
await this.scanModulesForDependencies(); // <<<<<<<<<<<<< (1)
this.calculateModulesDistance();
this.addScopedEnhancersMetadata();
this.container.bindGlobalScope();
}
public async scanModulesForDependencies(
modules: Map<string, Module> = this.container.getModules(),
) {
for (const [token, { metatype }] of modules) {
await this.reflectImports(metatype, token, metatype.name);
this.reflectProviders(metatype, token); // <<<<<<<<<<<<<< (2)
this.reflectControllers(metatype, token);
this.reflectExports(metatype, token);
}
}
public reflectProviders(module: Type<any>, token: string) {
const providers = [
...this.reflectMetadata(MODULE_METADATA.PROVIDERS, module),
...this.container.getDynamicMetadataByToken(
token,
MODULE_METADATA.PROVIDERS as 'providers',
),
];
providers.forEach(provider => {
this.insertProvider(provider, token); // <<<<<<<<<<<<<< (3)
this.reflectDynamicMetadata(provider, token);
});
}
public insertProvider(provider: Provider, token: string) {
const isCustomProvider = this.isCustomProvider(provider);
if (!isCustomProvider) {
return this.container.addProvider(provider as Type<any>, token); // <<<<<<<<<<<<< (4)
}
const applyProvidersMap = this.getApplyProvidersMap();
const providersKeys = Object.keys(applyProvidersMap);
const type = (
provider as
| ClassProvider
| ValueProvider
| FactoryProvider
| ExistingProvider
).provide;
if (!providersKeys.includes(type as string)) {
return this.container.addProvider(provider as any, token); // <<<<<<<<<<<<< (4)
}
// =================================================================
// below is for global injectables(ex, interceptor, guards..)
// registered using nestjs-provided constants (ex, APP_INTERCEPTOR)
// =================================================================
const providerToken = `${
type as string
} (UUID: ${randomStringGenerator()})`;
let scope = (provider as ClassProvider | FactoryProvider).scope;
if (isNil(scope) && (provider as ClassProvider).useClass) {
scope = getClassScope((provider as ClassProvider).useClass);
}
this.applicationProvidersApplyMap.push({
type,
moduleKey: token,
providerKey: providerToken,
scope,
});
const newProvider = {
...provider,
provide: providerToken,
scope,
} as Provider;
const factoryOrClassProvider = newProvider as
| FactoryProvider
| ClassProvider;
if (this.isRequestOrTransient(factoryOrClassProvider.scope)) {
return this.container.addInjectable(newProvider, token); // <<<<<<<<<<<<< (4)
}
this.container.addProvider(newProvider, token); // <<<<<<<<<<<<<< (4)
}
}
// packages/core/injector/container.ts
export class NestContainer {
public addProvider(
provider: Provider,
token: string,
): string | symbol | Function {
const moduleRef = this.modules.get(token);
if (!provider) {
throw new CircularDependencyException(moduleRef?.metatype.name);
}
if (!moduleRef) {
throw new UnknownModuleException();
}
return moduleRef.addProvider(provider); // <<<<<<<<<<<<< (5)
}
}
// packages/core/injector/module.ts
export class Module {
...
public addProvider(provider: Provider) {
if (this.isCustomProvider(provider)) {
return this.addCustomProvider(provider, this._providers);
}
this._providers.set(
provider,
new InstanceWrapper({ // <<<<<<<<<<<<<< (6)
token: provider,
name: (provider as Type<Injectable>).name,
metatype: provider as Type<Injectable>, // prototype object
instance: null, // registered without any instance
isResolved: false,
scope: getClassScope(provider),
durable: isDurable(provider),
host: this,
}),
);
return provider as Type<Injectable>;
}
}
Injector
Let’s go back to the Injector
. In this post, we will focus on Injector.loadPrototype()
and Injector.loadProvider()
, which are the main methods of Injector
called by InstanceLoade
.
Injector.loadPrototype()
First, if you look at the Injector.loadPrototype()
method, you’ll find that it creates an empty object by calling the Object.create()
. The object created in this way does not invoke the constructor method, so it is possible to create objects that have dependencies on other dependency objects. Most properties are still undefined because the objects which it depends on has not been injected. If you look at code implementation, you’ll find it registers an instance of a dependency object by updating instance properties in the InstanceWrapper
created during the dependency scan process by DependenciesScanner
.
// packages/core/injector/injector.ts
export class Injector {
public loadPrototype<T>(
{ token }: InstanceWrapper<T>,
collection: Map<InstanceToken, InstanceWrapper<T>>,
contextId = STATIC_CONTEXT,
) {
if (!collection) {
return;
}
const target = collection.get(token);
const instance = target.createPrototype(contextId);
if (instance) {
const wrapper = new InstanceWrapper({
...target,
instance, // initialized with instance for static context
});
collection.set(token, wrapper);
}
}
}
// packages/core/injector/instance-wrapper.ts
export class InstanceWrapper<T = any> {
...
set instance(value: T) {
this.values.set(STATIC_CONTEXT, { instance: value });
}
get instance(): T {
const instancePerContext = this.getInstanceByContextId(STATIC_CONTEXT);
return instancePerContext.instance;
}
}
Injector.loadInstance()
InstanceLoader
then invokes the Injector.loadInstance()
method internally. To avoid conflicts during the creation of dependency object instances registered to multiple modules, Injector
coordinates the creation of instances by manipulating the properties of InstancePerContext
objects (isPending
, isResolved
, donePromise
).
// packages/core/injector/injector.ts
export class Injector {
public async loadInstance<T>(
wrapper: InstanceWrapper<T>,
collection: Map<InstanceToken, InstanceWrapper>,
moduleRef: Module,
contextId = STATIC_CONTEXT,
inquirer?: InstanceWrapper,
) {
const inquirerId = this.getInquirerId(inquirer);
const instanceHost = wrapper.getInstanceByContextId(
this.getContextId(contextId, wrapper),
inquirerId,
);
if (instanceHost.isPending) {
return instanceHost.donePromise.then((err?: unknown) => {
if (err) {
throw err;
}
});
}
const done = this.applyDoneHook(instanceHost);
const token = wrapper.token || wrapper.name;
const { inject } = wrapper;
const targetWrapper = collection.get(token);
if (isUndefined(targetWrapper)) {
throw new RuntimeException();
}
if (instanceHost.isResolved) {
return done();
}
...
}
public applyDoneHook<T>(
wrapper: InstancePerContext<T>,
): (err?: unknown) => void {
let done: (err?: unknown) => void;
wrapper.donePromise = new Promise<unknown>((resolve, reject) => {
done = resolve;
});
wrapper.isPending = true;
return done;
}
}
Then, the procedure for creating instances through dependency injection proceeds. Metadata registered through decorators such as @Inject
and @Options
are parsed in this process.
- Call
resolveConstructorParams()
to parse the dependency information registered with the constructor, and then load the corresponding objects. - Calls the provided
callback
function inside theresolveConstructorParams()
. The callback function plays a key role in dependency injection, such as creating instances by injecting dependency objects registered in the constructor, and additionally injecting dependency objects registered in the properties.
After completing the above process, the creation of instances of all dependency objects (ex, Provider
, Controller
) registered in NestContainer
is completed.
// packages/core/injector/injector.ts
export class Injector {
public async loadInstance<T>(
wrapper: InstanceWrapper<T>,
collection: Map<InstanceToken, InstanceWrapper>,
moduleRef: Module,
contextId = STATIC_CONTEXT,
inquirer?: InstanceWrapper,
) {
...
// instantiation
try {
const callback = async (instances: unknown[]) => {
const properties = await this.resolveProperties(
wrapper,
moduleRef,
inject as InjectionToken[],
contextId,
wrapper,
inquirer,
);
const instance = await this.instantiateClass(
instances,
wrapper,
targetWrapper,
contextId,
inquirer,
);
this.applyProperties(instance, properties);
done();
};
await this.resolveConstructorParams<T>(
wrapper,
moduleRef,
inject as InjectionToken[],
callback,
contextId,
wrapper,
inquirer,
);
} catch (err) {
done(err);
throw err;
}
}
}
Injector.resolveConstructorParams()
export class Injector {
public async resolveConstructorParams<T>(
wrapper: InstanceWrapper<T>,
moduleRef: Module,
inject: InjectorDependency[],
callback: (args: unknown[]) => void | Promise<void>,
contextId = STATIC_CONTEXT,
inquirer?: InstanceWrapper,
parentInquirer?: InstanceWrapper,
) {
// 1. skip if it is a redundant execution
let inquirerId = this.getInquirerId(inquirer);
const metadata = wrapper.getCtorMetadata();
if (metadata && contextId !== STATIC_CONTEXT) {
const deps = await this.loadCtorMetadata(
metadata,
contextId,
inquirer,
parentInquirer,
);
return callback(deps);
}
// 2. parse param types in constructor
const isFactoryProvider = !isNil(inject);
const [dependencies, optionalDependenciesIds] = isFactoryProvider
? this.getFactoryProviderDependencies(wrapper)
: this.getClassDependencies(wrapper);
// 3. resolve individual parameters
let isResolved = true;
const resolveParam = async (param: unknown, index: number) => {
try {
if (this.isInquirer(param, parentInquirer)) {
return parentInquirer && parentInquirer.instance;
}
if (inquirer?.isTransient && parentInquirer) {
inquirer = parentInquirer;
inquirerId = this.getInquirerId(parentInquirer);
}
const paramWrapper = await this.resolveSingleParam<T>(
wrapper,
param,
{ index, dependencies },
moduleRef,
contextId,
inquirer,
index,
);
const instanceHost = paramWrapper.getInstanceByContextId(
this.getContextId(contextId, paramWrapper),
inquirerId,
);
if (!instanceHost.isResolved && !paramWrapper.forwardRef) {
isResolved = false;
}
return instanceHost?.instance;
} catch (err) {
const isOptional = optionalDependenciesIds.includes(index);
if (!isOptional) {
throw err;
}
return undefined;
}
};
const instances = await Promise.all(dependencies.map(resolveParam));
isResolved && (await callback(instances));
}
}
Closing
In this post, we’ve outlined InstanceLoader
and Injector
, which are key to creating instances and injecting dependencies at NestJS. In the next post, we’ll talk about NestApplication
.