DEV Community

Костя Третяк
Костя Третяк

Posted on • Edited on

NestJS vs. Ditsmod: injection scopes

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:

DI injector hierarchy in Ditsmod

Ditsmod has 4 static levels of DI injector hierarchy:

  1. 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);
  2. Module level. The provider instance is created once for each module;
  3. Route level. The provider instance is created once for each route;
  4. 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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 the CatsRepository 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

If you like NestJS, chances are you'll like Ditsmod more.

Top comments (5)

Collapse
 
micalevisk profile image
Micael Levi L. C. • Edited

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?

Collapse
 
kostyatretyak profile image
Костя Третяк

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).

Collapse
 
micalevisk profile image
Micael Levi L. C. • Edited

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.

Collapse
 
kostyatretyak profile image
Костя Третяк

As far as I understand, the error is related to Reflector, maybe due to the lack of reflect-metadata import. In Ditsmod, a similar error is also possible, but the documentation clearly states that reflect-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?

Collapse
 
kostyatretyak profile image
Костя Третяк • Edited

Ah, I did imagine a scenario with multi-import @nestjs/core. For example, this can happen when the module developer specified in dependencies on a specific version of @nestjs/core instead of specifying it in peerDependencies.

I think that such a scenario can easily be excluded, if you make an emphasis in the documentation on this matter.