Good application modularity is closely related to the injector tree hierarchy that Dependency Injection (DI) creates.
This article uses NestJS v10.0 and Ditsmod v2.38 for comparison. I am the author of Ditsmod.
DI injectors are sometimes referred to as DI containers, but since NestJS and Ditsmod have borrowed many concepts from Angular, this post will use the term "injectors" as 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 appears that NestJS v10.0 does not yet have the ability to instantiate providers at the module level. This leads to a degradation 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 a new instance of a particular provider each time 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. While this feature can improve application performance by several percent, it also increases the likelihood of "shooting yourself in the foot" when the developer creates a property in the controller for a particular HTTP request:
@Controller()
export class CatsController {
private propertyWithRequestContext: any;
@Get()
method1() {
// Works with this.propertyWithRequestContext
}
}
Now, if 10 requests come to method1()
, they will all overwrite propertyWithRequestContext
and interfere with each other.
In addition, if a default-scoped controller has a dependency on a request-scoped service, then such a controller automatically (without warning) becomes request-scoped as well. 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. Also, if the service is transient-scoped, then a similar scope change will not occur in the controller, introducing additional inconsistency into the NestJS architecture.
In NestJS v9.0, it became possible to create so-called durable providers to have request-scoped services and not have to rebuild the dependency tree for every request. Judging from 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 for each HTTP request. Regardless, performance with this controller is about the same as NestJS + Fastify with the default scope. When an application-level service tries to get a request-scoped service, Ditsmod throws an error that the 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.
Top comments (5)
I'd like to know why a new controller instance is created for every HTTP request. I see that having this solves the well mentioned issues that NestJS has. Also, in Ditsmod, could singleton controllers be a thing in the future or this is a design decision that must not change due to how it works?
Controllers are instantiated with each HTTP request so that variables specific to the HTTP request can be created at the controller class level. At this point, I don't see a noticeable benefit to having the controller be an application-level singleton, as Ditsmod's controllers are fast without this feature (which is also potentially buggy).
from time to time people face "dependency not found" error of some internal nestjs provider like
Reflector
from@nestjs/core
. Does Ditsmod have such 'flaw'?I mean, I still believe that this is more or less a setup issue since there's no good reason to allow multiple versions of
@nestjs/core
being loaded into our app (AFIAK) as this could lead to runtime errors due to some version mismatch, but I think that this is cumbersome for nodejs newcomers to reason about.As far as I understand, the error is related to
Reflector
, maybe due to the lack ofreflect-metadata
import. In Ditsmod, a similar error is also possible, but the documentation clearly states thatreflect-metadata
must be imported into the Node.js entry file.Regarding the multi-import with different versions of
@nestjs/core
, I can't imagine in which scenario it would be possible to accidentally do this. Can you give an example?Ah, I did imagine a scenario with multi-import
@nestjs/core
. For example, this can happen when the module developer specified independencies
on a specific version of@nestjs/core
instead of specifying it inpeerDependencies
.I think that such a scenario can easily be excluded, if you make an emphasis in the documentation on this matter.