Here, I am going to examine NestJS -- primarily focusing on its dependency injection --
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, andExampleModule
doesn't have anyimports
.Service1
- Has access toService2
, asOtherModule
provides them both (they're "peers"). Additionally, it has access toExampleService
, becauseOtherModule
imports
ExampleModule
, andExampleModule
exports
ExampleService
Service2
- Has access toService1
, it's peer, andExampleService
just likeService1
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
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.
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 moduleREQUEST
- A new instance is initialized during each requestTRANSIENT
- 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.
Also known as "Singleton", this is the rather typical DI model; each provider creates the value only once per module
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.
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
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 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.
- angular/angular#43784
- https://www.angulararchitects.io/en/aktuelles/angulars-future-without-ngmodules-lightweight-solutions-on-top-of-standalone-components/
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.
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.
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.
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
So, to recap, a Nest Module
is an ES module
where you import { Module }
and import
module
s containing other Module
s for your Module
's imports
and add exports
to your Module
before you export
your Module
from the module
.
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.
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.
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:
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) => {
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
intsconfig
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