DEV Community

Cover image for NestJS Discovery
maxence-lefebvre for SFEIR

Posted on

NestJS Discovery

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,
      /* ... */
    ];
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

Filter them on our custom Metadata,

const filteredProviders = wrappers.filter(
  ({ metatype }: InstanceWrapper) =>
    metatype && Reflect.getMetadata(REGISTRY_METADATA_KEY, metatype)
);
Enter fullscreen mode Exit fullscreen mode

And finally, group the instances 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),
    };
  },
  {}
);
Enter fullscreen mode Exit fullscreen mode

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

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

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,
      /* ... */
    ];
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

Find me on Twitter @maxence_lfbvr

Top comments (0)