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
  }
}

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;
  }
}

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.