0Pricing
NestJS Enterprise Backend APIs · 课时

使用 DiscoveryService 动态注册提供者

使用 DiscoveryService 和 MetadataScanner 在运行时扫描并连接提供者,为插件系统提供支持。

使用 DiscoveryService 动态注册提供者 是 CoddyKit 上的免费 NestJS Enterprise Backend APIs 课时。 这是第 2 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 NestJS Enterprise Backend APIs 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 NestJS Enterprise Backend APIs 课程共包含 4 节课。

本课时的部分内容尚未翻译,以英文显示。

The Plugin Wiring Problem

In a plugin architecture, you don't know at compile time which handlers, strategies, or adapters will exist. A hexagonal core defines ports; plugins supply adapters. The challenge: how does the framework find and wire those adapters without you hand-registering each one?

  • Hard-coded arrays in a module are brittle — every new plugin edits core code.
  • You want providers to self-declare their role via decorators, then be discovered at runtime.

NestJS ships @nestjs/core's DiscoveryService and MetadataScanner exactly for this. They let you scan the live DI container and react to metadata.

Marking Providers with a Decorator

The pattern starts with a custom decorator that stamps metadata onto a class. We use SetMetadata (or Reflector.createDecorator) so the scanner can later filter for it.

Here a @Plugin() decorator tags a class as a discoverable plugin and carries a name so the registry can key on it.

import { SetMetadata } from '@nestjs/common';

export const PLUGIN_KEY = 'app:plugin';

export interface PluginMeta {
  name: string;
}

export const Plugin = (meta: PluginMeta): ClassDecorator =>
  SetMetadata(PLUGIN_KEY, meta);

// A plugin author writes:
@Plugin({ name: 'csv-exporter' })
export class CsvExporter {
  export(rows: unknown[]): string {
    return rows.map((r) => JSON.stringify(r)).join('\n');
  }
}

What DiscoveryService Gives You

DiscoveryService is exported by the DiscoveryModule. Inject it and you get two key methods:

  • getProviders() — every provider instance wrapper in the application container.
  • getControllers() — every controller wrapper.

Each item is an InstanceWrapper with .instance (the live object), .metatype (the class), and .name. You filter these wrappers by reading metadata off the metatype with a Reflector.

import { Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { PluginRegistry } from './plugin.registry';

@Module({
  imports: [DiscoveryModule], // exposes DiscoveryService + MetadataScanner
  providers: [PluginRegistry],
  exports: [PluginRegistry],
})
export class PluginCoreModule {}

Scanning Providers on Bootstrap

Run discovery after the container is fully built. Implement OnModuleInit (or OnApplicationBootstrap if you need every module ready). Filter wrappers whose metatype carries your PLUGIN_KEY metadata.

Guard against null wrappers: some entries (value providers, request-scoped placeholders) have no metatype or no instance.

import { Injectable, OnModuleInit } from '@nestjs/common';
import { DiscoveryService, Reflector } from '@nestjs/core';
import { PLUGIN_KEY, PluginMeta } from './plugin.decorator';

@Injectable()
export class PluginRegistry implements OnModuleInit {
  private readonly plugins = new Map<string, object>();

  constructor(
    private readonly discovery: DiscoveryService,
    private readonly reflector: Reflector,
  ) {}

  onModuleInit(): void {
    for (const wrapper of this.discovery.getProviders()) {
      const { instance, metatype } = wrapper;
      if (!instance || !metatype) continue;
      const meta = this.reflector.get<PluginMeta>(PLUGIN_KEY, metatype);
      if (!meta) continue;
      this.plugins.set(meta.name, instance);
    }
  }

  get(name: string): object | undefined {
    return this.plugins.get(name);
  }
}

MetadataScanner for Method-Level Hooks

Sometimes the plugin point isn't the class but a method — e.g. @EventHandler('order.created') on individual methods. MetadataScanner walks every method of an instance's prototype so you can read per-method metadata.

Use getAllMethodNames(prototype) (modern API) and inspect each handler with the Reflector.

import { Injectable, OnModuleInit } from '@nestjs/common';
import { DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core';

export const EVENT_KEY = 'app:event';

@Injectable()
export class EventBinder implements OnModuleInit {
  constructor(
    private readonly discovery: DiscoveryService,
    private readonly scanner: MetadataScanner,
    private readonly reflector: Reflector,
  ) {}

  onModuleInit(): void {
    for (const w of this.discovery.getProviders()) {
      if (!w.instance || !w.metatype) continue;
      const proto = Object.getPrototypeOf(w.instance);
      for (const method of this.scanner.getAllMethodNames(proto)) {
        const event = this.reflector.get<string>(EVENT_KEY, proto[method]);
        if (event) this.bind(event, w.instance, method);
      }
    }
  }

  private bind(event: string, target: object, method: string): void {
    // register target[method] as a listener for `event`
  }
}

The Method-Level Decorator

Pair the binder with a method decorator. Note it's a MethodDecorator — SetMetadata attaches the value to the method's descriptor.value, which is exactly what reflector.get(EVENT_KEY, proto[method]) reads.

This keeps the wiring declarative: a plugin author adds an annotation and the core binds it — no manual emitter.on(...) calls.

import { SetMetadata } from '@nestjs/common';
import { EVENT_KEY } from './event.binder';

export const OnEvent = (event: string): MethodDecorator =>
  SetMetadata(EVENT_KEY, event);

@Injectable()
export class InventoryPlugin {
  @OnEvent('order.created')
  reserveStock(payload: { orderId: string }): void {
    // adjust stock for payload.orderId
  }

  @OnEvent('order.cancelled')
  releaseStock(payload: { orderId: string }): void {
    // restore stock
  }
}

Modeling Discovery in Plain TypeScript

Strip away NestJS and the core idea is simple: a registry maps a key to an instance discovered from a list, then routes calls by key. This standalone model captures the registry semantics you'll wire to DiscoveryService.

The exact same Map-based lookup powers the real registry — only the source of instances differs.

interface Exporter {
  readonly name: string;
  export(rows: object[]): string;
}

class CsvExporter implements Exporter {
  name = 'csv';
  export(rows: object[]): string {
    return rows.map((r) => Object.values(r).join(',')).join('\n');
  }
}

class JsonExporter implements Exporter {
  name = 'json';
  export(rows: object[]): string {
    return JSON.stringify(rows);
  }
}

class Registry {
  private map = new Map<string, Exporter>();
  register(...plugins: Exporter[]): void {
    for (const p of plugins) this.map.set(p.name, p);
  }
  run(name: string, rows: object[]): string {
    const p = this.map.get(name);
    if (!p) throw new Error('Unknown exporter: ' + name);
    return p.export(rows);
  }
}

const reg = new Registry();
reg.register(new CsvExporter(), new JsonExporter());
const data = [{ id: 1, sku: 'A' }, { id: 2, sku: 'B' }];
console.log(reg.run('csv', data));
console.log(reg.run('json', data));

Discovery Timing and Lifecycle

Timing matters. The DI container is only complete at certain lifecycle phases:

  • onModuleInit — fires per module after its providers resolve. Fine if all plugins live in one module.
  • onApplicationBootstrap — fires once, after all modules initialized. Safest for cross-module plugin scanning.

Scanning too early yields an empty or partial provider list. Prefer onApplicationBootstrap when plugins can ship in feature modules loaded later.

import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { DiscoveryService, Reflector } from '@nestjs/core';
import { PLUGIN_KEY, PluginMeta } from './plugin.decorator';

@Injectable()
export class PluginRegistry implements OnApplicationBootstrap {
  private readonly plugins = new Map<string, object>();

  constructor(
    private readonly discovery: DiscoveryService,
    private readonly reflector: Reflector,
  ) {}

  onApplicationBootstrap(): void {
    const found = this.discovery
      .getProviders()
      .filter((w) => w.instance && w.metatype)
      .map((w) => ({
        meta: this.reflector.get<PluginMeta>(PLUGIN_KEY, w.metatype!),
        instance: w.instance,
      }))
      .filter((x) => x.meta);
    for (const { meta, instance } of found) {
      this.plugins.set(meta!.name, instance);
    }
  }
}

Scope Pitfalls: REQUEST and TRANSIENT

Discovery sees singletons cleanly. Beware non-default scopes:

  • Scope.REQUEST / Scope.TRANSIENT providers may have wrapper.instance === null at bootstrap — there's no single instance to cache.
  • A discovered request-scoped instance would be stale and leak per-request state if you cache it.

Rule of thumb: keep plugins singleton-scoped. If a plugin truly needs request data, discover the class and resolve a fresh instance per request via ModuleRef.resolve() instead of caching the instance.

import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';

@Injectable()
export class ScopedPluginInvoker {
  constructor(private readonly moduleRef: ModuleRef) {}

  // metatype was discovered earlier; resolve fresh per request
  async invoke<T>(metatype: new (...a: any[]) => T): Promise<T> {
    return this.moduleRef.resolve(metatype, undefined, { strict: false });
  }
}

Validating and Guarding the Registry

A plugin system that silently swallows duplicates or missing contracts is a debugging nightmare. Add guards during discovery:

  • Duplicate names — throw, don't overwrite, so two plugins can't collide on one key.
  • Contract check — verify the instance implements the expected method shape before trusting it.

Failing fast at bootstrap turns a runtime plugin bug into a clear startup error.

private register(name: string, instance: object): void {
  if (this.plugins.has(name)) {
    throw new Error(`Duplicate plugin name: ${name}`);
  }
  if (typeof (instance as { export?: unknown }).export !== 'function') {
    throw new Error(`Plugin ${name} missing export()`);
  }
  this.plugins.set(name, instance);
}

Why This Fits Hexagonal Design

Discovery-based registration is the runtime glue of ports and adapters:

  • The core defines a port (an interface) and a registry keyed by capability.
  • Each adapter/plugin declares itself with a decorator — it depends on the core's contract, never the reverse.
  • Adding a capability means dropping in a new annotated provider; no edits to core wiring.

This inverts the dependency direction (the Dependency Inversion Principle) and keeps the core closed for modification but open for extension — the heart of plugin architecture.

Quick Check

You build a plugin registry that caches each discovered provider's .instance in a Map during onApplicationBootstrap. One plugin is declared with Scope.REQUEST. What goes wrong and what's the right fix?

Recap

You built a runtime plugin system on NestJS discovery primitives:

  • Tag plugins with a metadata decorator (SetMetadata + a PLUGIN_KEY), class-level for whole plugins, method-level for handlers.
  • Scan the container with DiscoveryService.getProviders(), filtering wrappers via Reflector; use MetadataScanner.getAllMethodNames() for per-method hooks.
  • Time it with onApplicationBootstrap for cross-module safety, and skip wrappers lacking instance/metatype.
  • Guard against duplicate keys and contract violations; keep plugins singleton-scoped, resolving via ModuleRef only when request scope is genuinely needed.

The payoff: a hexagonal core that's closed for modification yet open to new annotated adapters — zero core edits per plugin.

常见问题解答

「使用 DiscoveryService 动态注册提供者」课时是免费的吗?

是的 — 「使用 DiscoveryService 动态注册提供者」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 NestJS Enterprise Backend APIs 课程的其余内容,请升级到 CoddyKit PRO。 NestJS Enterprise Backend APIs 课程共包含 4 节课。

「使用 DiscoveryService 动态注册提供者」这节课中我会学到什么?

使用 DiscoveryService 和 MetadataScanner 在运行时扫描并连接提供者,为插件系统提供支持。 你通过在浏览器中直接运行的动手代码来练习 NestJS Enterprise Backend APIs,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 NestJS Enterprise Backend APIs 需要有经验吗?

无需任何先前经验。CoddyKit 上的 NestJS Enterprise Backend APIs 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 2 节课,共 4 节。

「使用 DiscoveryService 动态注册提供者」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 NestJS Enterprise Backend APIs 课中编写并运行代码吗?

能。每节 NestJS Enterprise Backend APIs 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 用于领域隔离的端口与适配器
  2. 使用 DiscoveryService 动态注册提供者
  3. 延迟加载模块与功能开关
  4. 使用模块引用 API 提供扩展点
← 返回 NestJS Enterprise Backend APIs