NestJS vs. Ditsmod: injection scopes
Good application modularity is closely related to the injector tree hierarchy that Dependency Injection (DI) creates.
In this post, NestJS v10.0 and Ditsmod v2.38 are used for comparison. I am the author of Ditsmod.
DI injectors are sometimes referred to as DI containers, but since NestJS and Ditsmod have taken many concepts from Angular, this post will use the term “injectors” (since it does in Angular).
Scopes in NestJS
In NestJS there are no explicitly defined levels of the DI injector hierarchy, but there are scopes that implicitly refer to such a hierarchy:
DEFAULT
- A single instance of the provider is shared across the entire application. The instance lifetime is tied directly to the application lifecycle. Once the application has bootstrapped, all singleton providers have been instantiated. Singleton scope is used by default.REQUEST
- A new instance of the provider is created exclusively for each incoming request. The instance is garbage-collected after the request has completed processing.TRANSIENT
- Transient providers are not shared across consumers. Each consumer that injects a transient provider will receive a new, dedicated instance.
It seems that NestJS (v10.0) does not yet have the ability to instantiate providers at the module level. This leads to deterioration of the modularity of applications:
- Exception filters module-scoped;
- Can I use Interceptor in Module ? What should I do?;
- Allow APP_* providers to be module-scoped rather than global.
DI injector hierarchy in Ditsmod
Ditsmod has 4 static levels of DI injector hierarchy:
- Application level. Providers are instantiated only once during the application’s life cycle (this is the equivalent of the
DEFAULT
scope in NestJS, but in Ditsmod this is instantiated on the first request, while in NestJS the instance is instantiated at application startup); - Module level. The provider instance is created once for each module;
- Route level. The provider instance is created once for each route;
- HTTP request level. A provider instance is created once for each HTTP request (this is the equivalent of the
REQUEST
scope in NestJS).
In addition, at each of these levels, Ditsmod has the ability to create each time a new instance of a specific provider without using the injector cache (this is the equivalent of the TRANSIENT
scope in NestJS).
Features of controllers in NestJS
By default, NestJS instantiates a controller as a singleton. This feature, on the one hand, allows you to increase the performance of the application by several percent, but on the other hand, it increases the probability of “shooting yourself in the foot” if the developer in the controller creates a property for a specific HTTP request:
@Controller()
export class CatsController {
private propertyWithRequestContext: any;
}
In addition, if a controller with a default scope has a dependency on a request-scoped service, then such a controller automatically (without warning) also becomes request-scoped. In this way, the developer cannot rely on properties in the controller at all, because it is not clear what scope the controller will have as a result. And one more thing: if the service is transient-scoped, then a similar scope change will not occur in the controller, this introduces additional inconsistency into the NestJS architecture.
In NestJS v9.0, it became possible to create so-called durable providers in order to have request-scoped services and, at the same time, not to recreate the dependency tree for each request. Judging by my tests, such services work almost as slowly as regular request-scoped services, but another complication of the NestJS application architecture has been added.
Features of controllers in Ditsmod
A controller instance is created every time for every HTTP request. Regardless, performance with this controller is about the same as NestJS + Fastify with the default scope. If a service at the application level wants to get a request-scoped service, Ditsmod throws an error that this service is not found (the injector higher in the hierarchy does not see its child injectors).
Getting current injector
The NestJS documentation says:
Occasionally, you may want to resolve an instance of a request-scoped provider within a request context. Let’s say that
CatsService
is request-scoped and you want to resolve theCatsRepository
instance which is also marked as a request-scoped provider. In order to share the same DI container sub-tree, you must obtain the current context identifier instead of generating a new one
In the following example, the isSameInjector()
method compares the CatsRepository
instance that NestJS returns in the constructor with the instance returned by the this.moduleRef.resolve()
method. If NestJS uses the same injector in both cases, isSameInjector()
should return true
:
import { REQUEST, ModuleRef, ContextIdFactory } from '@nestjs/core';
import { CatsRepository } from './cats-repository';
@Injectable()
export class CatsService {
constructor(
@Inject(REQUEST) private request: Record<string, unknown>,
private moduleRef: ModuleRef,
private catsRepository: CatsRepository
) {}
async isSameInjector() {
const contextId = ContextIdFactory.getByRequest(this.request);
const catsRepository = await this.moduleRef.resolve(CatsRepository, contextId);
return catsRepository === this.catsRepository;
}
}
In Ditsmod, the same thing can be done much easier, because if CatsService
and CatsRepository
are request-scoped, they share the same injector:
import { injectable, Injector } from '@ditsmod/core';
import { CatsRepository } from './cats-repository';
@injectable()
export class CatsService {
constructor(
private injector: Injector,
private catsRepository: CatsRepository
) {}
isSameInjector() {
const catsRepository = this.injector.get(CatsRepository);
return catsRepository === this.catsRepository;
}
}
Conclusion
If you like NestJS, chances are you’ll like Ditsmod more.