Skip to content

Instantly share code, notes, and snippets.

@ehaynes99
Last active January 2, 2024 20:15
Show Gist options
  • Save ehaynes99/41d5c98263ead6041c0279c1eaef4a3a to your computer and use it in GitHub Desktop.
Save ehaynes99/41d5c98263ead6041c0279c1eaef4a3a to your computer and use it in GitHub Desktop.

Critique of NestJS

Here, I am going to examine NestJS -- primarily focusing on its dependency injection --

The basics

Nest's DI system is based on a custom-defined module system. For every service defined in the application, there is a module file that "owns" that service. I use the term "service" loosely here to simply mean "anything we want to be part of the DI system". For example:

@Injectable()
export class ExampleService {
  // ... do stuff
}

@Module({
  providers: [ExampleService],
  exports: [ExampleService],
})
export class ExampleModule {}

providers are things that module makes available to everything else provided by that module, as well as the module itself. exports are the things it makes available for other modules to use. It requires both, because, in essence, it's providing the value to itself, then declaring that it wants to expose that value externally. To demonstrate, let's make another module that's a little more complex:

@Module({
  imports: [ExampleModule],
  providers: [Service1, Service2],
  exports: [Service2],
})
export class OtherModule {}

The imports declaration draws in the exports of some other module, and exposes those to all of its provided values. I use the term "provided values" here, because it's an important distinction. Modules expose things ONLY to their providers. To clarify, let's list who can see what.

To clarify, let's list what each service here can access (let's ignore globals for now, covered later):

  • ExampleService - Doesn't have access to anything. It can't refer to itself (circular dependency), ExampleModule doesn't have any other providers, and ExampleModule doesn't have any imports.
  • Service1 - Has access to Service2, as OtherModule provides them both (they're "peers"). Additionally, it has access to ExampleService, because OtherModule imports ExampleModule, and ExampleModule exports ExampleService
  • Service2 - Has access to Service1, it's peer, and ExampleService just like Service1

Thus, Service2 would be valid if defined like:

@Injectable()
export class Service2 {
  constructor(
    private exampleService: ExampleService,
    private service1: Service1,
  ) {}
}

Another way to look at this is that, effectively, a module's providers are a combination of all of the exports of imported modules, plus all of the providers that it explicitly declares itself. This contrived code is similar to what happens under the hood:

// pseudocode
@Module({
  providers: [
    ...load(ExampleModule).exports,
    Service1,
    Service2,
  ],
  exports: [Service2],
})
export class OtherModule {}

Important notes:

  • Modules only provide things to their direct descendants

To be complete, let's list some things that the modules CANNOT do:

  • cannot provide a value "up the chain" to an imported module

No mechanism to share instances across modules besides global scoping

If you've worked with other dependency injection frameworks Because Nest modules are isolated, each thing a module imports is available only to those values defined in its providers.

Essentially, there is no mechanism to say "import the module initialized elsewhere", nor is there a mechanism to supply an imported module to another imported module.


Type Safety


Injection Scope

Nest defines a concept they refer to as Inject scopes. In summary, there are 3 "scopes":

  • DEFAULT (a.k.a. "Singleton") - Initialized once for the module
  • REQUEST - A new instance is initialized during each request
  • TRANSIENT - A new instance is initialized every time the value is injected

To begin with, the term "scope" here is a complete misnomer. The term "scope" refers to the visibility of a value within an application. In a Nest application, the scope is determined by the nature of how provided values are exposed through the module chain, outlined above. These might better have been referred to as "lifecycle".

Terminology aside, there's another conceptual problem with these: they're defined in the wrong place.

At a glance, these seem like a good idea. When you're building your application and you define a module to be a small, self-contained "bundle" of behavior, it seems logical that you're in a position to determine what the lifecycle of provided values should be. However, you can't specify the lifecycle of any of the values provided by imported modules. Those are the types of values to which you would actually want to apply these. Maybe you want a dedicated logger for each class so you can prefix its messages, or maybe you load data based on request params and want that to be recreated on each request. The only way to do that is to define that in the imported module. THAT module is NOT in a position to make that determination. When writing a logger library, you can't know if the consumer of that library wants TRANSIENT loggers or not.

These have nothing to do with that. The scope of provided values is outlined above, and in the terms of the Nest module system,

This would have been better referred to as "lifecycle". They have nothing at all to do with scope.

This would be better named "lifecycle", as it has absolutely nothing to do with scope.

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. Let's break these down.

DEFAULT

Also known as "Singleton", this is the rather typical DI model; each provider creates the value only once per module

"Scope" is a misnomer

The term "scope" refers to the visibility of a value within an application. If a value is "in scope" at a particular point in the code, it is visible or available for use.

This would have been better referred to as "lifecycle". They have nothing at all to do with scope.

This would be better named "lifecycle", as it has absolutely nothing to do with scope.

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.

Scope is defined in the wrong place

There is a fundamental flaw in the entire concept of "scopes": they're defined in the wrong place. With modules used in your application, these generally make sense for its providers. You're defining a self-contained piece of functionality in your application, so you're in the position to decide the lifecycle of the provided values.

In the previous example, we demonstrated how a module may declare itself to be global. However, this is the wrong place for this determination. The CONSUMER of the module should get to dictate the visibility scope. Scope should be able to be dictate by the CONSUMER of the module. It shouldn't be up to a library author to determine the scope of use, it should be up the application. Nest does not provide any mechanism to a

Nest modules are a low-resolution representation of a dependency matrix

Modules conflate dependency with creation

The Wikipedia page for Dependency injection makes the following statement:

Dependency injection is one form of the broader technique of inversion of control.
A client who wants to call some services should not have to know how to construct
those services. Instead, the client delegates to external code (the injector).
The client is not aware of the injector. The injector passes the services, which
might exist or be constructed by the injector itself, to the client. The client
then uses the services.

However, with Nest's manner of providing options to a module.

Aside from global modules (which are, apparently, a bad pattern), there is no mechanism in Nest to say "import the module that was configured elsewhere".

Decorators are the wrong tool for the job

Decorators are intended to attach metadata to a class, property, or method. The class defines its own contract; the types of its constructor parameters, the methods instances have, etc. Nest's module system, however, is using the decorator itself to define the contract. The module classes themselves are usually empty. In effect, the class is just a dummy value to allow the framework to use a decorator in isolation.

As TypeScript users are generally aware, types are erased at runtime, providing only compile time benefits. However, decorators are effectively erased at compile time. You can't refer to them. They can type their own parameters, but there is no mechanism to inspect those, and there is no mechanism to define "class that must be decorated with @X".

Even with the parameterization strategies outlined above, the class itself has no contract; the register functions are static, which could as easily be functions isolated from the class entirely.

For example, let's say we are building a library, and we want to expose an ExampleService. The module for that might look like:

@Module({
  imports: [SomethingWeNeed],
  providers: [ExampleService],
  exports: [ExampleService],
})
export class ExampleModule {}

Then, we package up the library and publish it to npm. With anything living within the bounds of the type system, we can simply cmd+click and check the type definition. Here, however, we get:

export declare class ExampleModule {}

The entire api contract is stripped out of the domain of the typesafe language and shifted into the completely untyped, ethereal realm of reflect-metadata. In practice, this means that the compiler can't help you at all to determine whether you've imported the right module to provide a value. It's now purely a runtime problem

The parameters passed to the @Module decorator are defined as ModuleMetadata. That's not metadata, it's the entire contract of the module!

This means that it's not even possible to use the type system to connect services with their modules. The whole module system should have simply been that interface.

// not real syntax!
const exampleModule: Module = {
  imports: [SomethingWeNeed],
  providers: [ExampleService],
  exports: [ExampleService],
}

At very most, the @Module decorator should have been an empty one that could be applied to a class:

// not real syntax!
@Module()
export class ExampleModule {
  readonly imports = [SomethingWeNeed];
  readonly providers = [ExampleService];
  readonly exports = [ExampleService];
}

Nest claims to be heavily influenced by Angular. The reason Angular has NGModules is, primarily, because it needs a JavaScript mechanism to Well, Angular is moving away from modules.


Nest's way or the highway

Over the course of my career, I've learned to be extremely skeptical of frameworks that have an "all in" strategy. To use Nest, you have to buy in wholesale to its module system. There's no easing into it, and if you think that's hard, just wait until you want to get OUT.

Nest is definitely one of these. Interoperability with There's a "real" library, and then there is a Nest version of that library.

Aside from class injection, the entire DI framework is untyped

Unless the thing being injected is a Nest @Injectable class, there's not any mechanism for typechecking available ANYWHERE in Nest's dependency injection system.

Providers might as well be untyped

At the provider level, there are several different options for the style. They all suffer from the same issue, but let's take the most simple, the ValueProvider<T>:

export interface ValueProvider<T = any> {
  provide: string | symbol | Type<any> | Abstract<any> | Function;
  useValue: T;
}

On the surface, it looks like we're getting type checking. There's a generic parameter for the type of the value, right? However, let's look at how they're used in ModuleMetadata:

export interface ModuleMetadata {
  // ...
  providers?: Provider[];
}

It's immediately dumped into an array that drops all of the type information, never to be heard from again. We don't reference the provider anywhere else, including at the point where we inject the value, where we refer only to the untyped provide token.

In short, the types on providers provide types for the various flavors of provider, but the generic parameter intended to represent the type of value being provided is utterly pointless.

Decorators destroy type-safety & editor tooling

Initially, I was going to say that decorators are the wrong tool for initialization, but frankly, decorators are the wrong tool for every job. They're a half-assed feature that lives entirely outside of the type system. There is no mechanism in typescript to define a type like ClassDecoratedWith<SomeDecorator>. They're also incapable of providing any type information about the thing being decorated. They're even ripped out of type definitions. In practical terms, this is WORSE than just casting the thing to any and wantonly assigning stuff to it, as at least that could be inspected at runtime.

Nest, however, is using it to define the entire api contract of every module, so ALL of that excluded from type checking, Intellisense, even visual inspection in the type definition for a library.

For example, let's say we are building a library, and we want to expose an ExampleService. The module for that might look like:

@Module({
  imports: [SomethingWeNeed],
  providers: [ExampleService],
  exports: [ExampleService],
})
export class ExampleModule {}

Then, we package up the library and publish it to npm. Someone else then imports our library:

@Module({
  imports: [ExampleModule]
})
export class AppModule {}

With anything living within the bounds of the type system, we can simply cmd+click and get a wealth of information about what it imports and what it exports. With decorators, however, we get:

export declare class ExampleModule {}

You get no compile time help for:

  • checking if you have actually provided what it needs to import

This terminology sounds vaguely familiar...

So, to recap, a Nest Module is an ES module where you import { Module } and import modules containing other Modules for your Module's imports and add exports to your Module before you export your Module from the module.

When boilerplate is all that remains

In the docs about global modules, they make this statement:

Unlike in Nest, Angular providers are registered in the global scope. Once defined,
they're available everywhere. Nest, however, encapsulates providers inside the
module scope. You aren't able to use a module's providers elsewhere without first
importing the encapsulating module.

They go on to say that the option to make modules global is there to reduce boilerplate, but is a bad practice. They never state why, but there are vague in various articles that suggest that, without this, it would be difficult to figure out the dependency matrix of the application.

Nonsense.

This doesn't solve the problem, it makes it far worse. The dependency matrix of your application from a logical standpoint is not really even related to the Nest module system. The dependency matrix of an application is comprised of service to service dependencies. The module dependencies are just framework bloat that at best tangentially related to the dependency matrix. But the actual JS module system dependency matrix now has all of those service dependencies, and all of those module dependencies.

In fact, a module could be loaded up with unused imports of other modules that none of its services use. If you have a service that defines an unused value, your linter will inform you of this, and through various mechanisms like precommit hooks or CI tasks, prevent that code from making it into the master branch.

Overly cumbersome initialization

Nest uses a decorator @Module as the core of its dependency injection system. Decorators being just a thin layer of syntactic sugar around a function, they're essentially the same as executing code in the root of a module. Any behavior in a decorator takes place when node loads the file. While fine when you can define a dependency tree in a purely static fashion, this is an inadequate solution when ANY logic is required for initialization.

The docs actually link to this dev.to article: https://dev.to/nestjs/advanced-nestjs-how-to-build-completely-dynamic-nestjs-modules-1370

There's a GitHub link at the end of the article: https://github.com/nestjsplus/massive

I've learned that any time you hear the words "advanced" and "NestJS" in the same sentence, some ridiculously complicated spaghetti code is sure to follow. This one does not dissapoint. What this 455 line monostrosity actually does is:

  • pass configuration options to the default export of the massive library

That's it. If "advanced" means "no one but very senior engineers will be able to understand it", then mission accomplished.

Poor interoperability with ANYTHING else

Nest has patterns in place for creating your services. They must be classes with specific decorators, and then must have a module to expose the class. Unfortunately, working with ANYTHING that's not explicitly written in this manner is very poorly suppported. Any external library (even ones in our own repo) that require any form of inialization have 2 options:

Option 1: Wrap some Nest around it

Option 2: Token based injection with ZERO type safety

Alternately, you can provide arbitrary values using either a string or symbol (should really use the latter to prevent collisions) as a token, and then passing that token to the @Inject decorator:

@Module({
  providers: [
    { provide: 'SOME_OBJECT', useValue: { some: 'object' } },
  ],
})

// elsewhere
class SomeService {
  // No compile error...
  constructor(@Inject('SOME_OBJECT') obj: string) {}
}

This compiles fine, because the @Inject decorator, the foundational building block of the entire injection system, doesn't have any type enforcement on the target:

export function Inject<T = any>(token?: T) {
  return (target: object, key: string | symbol, index?: number) => {

Uses classes for payload modelling... in JavaScript!

I wrote a whole article on this that breaks down the many reasons why this is a bad choice: https://gist.github.com/ehaynes99/84501b21dc838d5a43aa3c13d954e6c9

But in summary:

  • it forces you to turn off strictPropertyInitialization in tsconfig for the entire appliation
  • basically the entire Internet accepted JSON as the standard for payloads due to readability. JSON stands for "JavaScript Object Notation". So we're using a nice declarative JavaScript syntax in our serialized data, but not our... JavaScript
  • they completely ruin the spread operator, which is one of the best features of any language ever
  • trying to stuff metadata in the form of decorators into object itself leads to a lot of disparate types that drift into inconsistency
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment