[NestJS Deep Dive] 03. InstanceLoader and Injector

Donghyung Ko
7 min readNov 28, 2022

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

  1. NestFactory
  2. @Module and DynamicModule
  3. 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.

  1. Call resolveConstructorParams() to parse the dependency information registered with the constructor, and then load the corresponding objects.
  2. Calls the provided callback function inside the resolveConstructorParams(). 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.

--

--