Context
There is quite a feature available in NestJS that is, as of today, still undocumented.
I recently joined a new project, and there is a monitoring service
that needs to access all repositories
running in our app.
I was surprised that there didn't seem to be a better way that injecting manually all of them:
@Injectable()
export class MonitoringService {
private readonly repositories: Repository[];
constructor(
fooRepository: FooRepository,
barRepository: BarRepository
/* ... */
) {
this.repositories = [
fooRepository,
barRepository,
/* ... */
];
}
}
As I was discovering this service, a few things came in mind:
How many times did my team forget to add their repositories in this service ?
I can't fathom how much pain they felt to maintain this list and how poor the DX must be.
I don't want to do this.
Discovering my repositories: how to ?
There are already a lot of decorators in the NestJS ecosystem, and they mostly work all the same: by setting Reflection Metadata to the target.
So we are going to play like them, by first tagging our repositories with a custom Metadata.
Once we tagged them, we will ask the DiscoveryService
to give us all the registered providers
, with this.discoveryService.getProviders()
.
This method returns us a collection of type InstanceWrapper = { metatype, name, instance, ... }
.
The custom Metadata, we used to tag our services with, will be linked to the wrapper.metatype
.
Pimp my services
So let's start by doing the same and define a custom metadata through a custom decorator:
/// `registry.constants.ts`
export const REGISTRY_METADATA_KEY = Symbol('__my-app--registry__');
///
import { SetMetadata } from '@nestjs/common';
import { REGISTRY_METADATA_KEY } from './registry.constants';
export const Discover = (v: unknown) => SetMetadata(REGISTRY_METADATA_KEY, v);
NB: SetMetadata
is documented for route handlers, with the usage of NestJS's Reflector
.
Now we can start to tag the repositories:
import { Discover } from '@org/shared/kernel/registry';
@Injectable()
@Discover('repository')
export class FooRepository implements Repository {}
@Injectable()
@Discover('repository')
export class BarRepository implements Repository {}
You know the drill, we can also define a custom Repository
decorator:
import { Discover } from '@org/shared/kernel/registry';
import { composeDecorators } from '@org/shared/lang-extensions/typescript';
export const DiscoverableRepository = composeDecorators(
Injectable(),
Discover('repository')
);
///
import { DiscoverableRepository } from '@org/shared/data-access';
@DiscoverableRepository
export class FooRepository implements Repository {}
@DiscoverableRepository
export class BarRepository implements Repository {}
Bring them all
Let's define our Registry which will use the DiscoveryService to find all providers tagged with our custom Metadata
.
We will first wait for onModuleInit
to make sure all providers are registered.
Then we will retrieve all providers instance wrappers
from the DiscoveryService
,
type InstanceWrapper = {
metatype: unknown;
name: string;
instance: unknown
};
const wrappers: InstanceWrapper[] =
this.discoveryService.getProviders();
Filter them on our custom Metadata,
const filteredProviders = wrappers.filter(
({ metatype }: InstanceWrapper) =>
metatype && Reflect.getMetadata(REGISTRY_METADATA_KEY, metatype)
);
And finally, group the instance
s by the value of the aforementioned Metadata.
const groupedProviders = filteredProviders.reduce(
(acc, { metatype, instance }: InstanceWrapper) => {
const type = Reflect.getMetadata(REGISTRY_METADATA_KEY, metatype);
return {
...acc,
[type]: (acc[type] || []).concat(instance),
};
},
{}
);
After some refactoring:
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DiscoveryService } from '@nestjs/core';
import iterate from 'iterare';
import { REGISTRY_METADATA_KEY } from './registry.constants';
type InstanceWrapper = {
metatype: unknown;
name: string;
instance: unknown
};
@Injectable()
export class Registry implements OnModuleInit {
private providers: Record<string | symbol, unknown[]> = {};
constructor(private readonly discoveryService: DiscoveryService) {}
public getProviders<T extends unknown[]>(key?: string | symbol): T {
const providers = key
? this.providers[key]
: Object.values(this.providers).flat();
return (providers || []) as T;
}
onModuleInit(): void {
this.providers = this.scanDiscoverableInstanceWrappers(
this.discoveryService.getProviders()
);
}
private scanDiscoverableInstanceWrappers(
wrappers: InstanceWrapper[]
) {
return iterate(wrappers)
.filter(({ metatype }) => metatype && this.getMetadata(metatype))
.reduce((acc, { metatype, instance, name }) => {
const type = this.getMetadata(metatype);
return {
...acc,
[type]: (acc[type] || []).concat(instance),
};
}, {});
}
private getMetadata(metatype: unknown) {
return Reflect.getMetadata(REGISTRY_METADATA_KEY, metatype);
}
}
Don't forget to import the DiscoveryModule
!
import { Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { Registry } from './registry';
@Module({
imports: [DiscoveryModule],
providers: [Registry],
exports: [Registry],
})
export class RegistryModule {}
And in the darkness, bind them.
Now that we tagged our services and now that we can find them all, let's refactor our pain point:
Before:
@Injectable()
export class MonitoringService {
private readonly repositories: Repository[];
constructor(
fooRepository: FooRepository,
barRepository: BarRepository
/* ... */
) {
this.repositories = [
fooRepository,
barRepository,
/* ... */
];
}
}
After:
import { OnModuleInit } from '@nestjs/common';
import { Registry } from '@org/shared/kernel/registry';
@Injectable()
export class MonitoringService implements OnModuleInit {
private repositories: Repository[] = [];
constructor(private readonly registry: Registry) {}
onModuleInit(): void {
this.repositories = this.registry.getProviders<Repository[]>('repository');
}
}
Thoughts
No really private providers
Even if your tagged providers aren't exported anywhere, NestJS's DiscoveryService
will be able to discover them.
I find this behaviour quite great, since it allows me to discover them without forcing me to expose services I don't want available for DI.
However, this worries me since nothing can really reassure me that another module isn't mutating/patching my "private" providers instances at runtime.
Controllers
DiscoveryService
exposes getControllers()
too, since they are treated differently than a provider in NestJS.
You may need to extend the previous snippets to handle them as well, if you need.
Global
I couldn't tell if it would be a good idea to make RegistryModule a global module.
Lifecycle
I hooked the explorer to onModuleInit
but I probably should have waited to load the providers later, like during onApplicationBootstrap
.
I am not confident enough in my knowledge of the lifecycle to tell today.
I guess all providers are already registered during onModuleInit
?
Sources
- Example repo: https://github.com/maxence-lefebvre/example-nestjs-discovery-service
-
DiscoveryService
code: https://github.com/nestjs/nest/blob/master/packages/core/discovery/discovery-service.ts - Cover image: The Y at Amsterdam, with the Frigate 'De Ploeg'. Date: 17th century. Institution: Rijksmuseum. Provider: Rijksmuseum. Providing Country: Netherlands. Public Domain
Find me on Twitter @maxence_lfbvr
Top comments (2)
Your post great, but missing an important detail from your code. Specifically, the discovered providers still need to be defined in the the providers of an imported module. Otherwise, they will never be discovered because they don't exist from the perspective of DiscoveryService.
github.com/maxence-lefebvre/exampl...
Nice