NestJS Enterprise Backend APIs · 강의

모듈 참조 API를 활용한 확장 지점

ModuleRef를 통해 범위가 지정된 의존성을 명령형으로 확인하여 서드파티 확장 기능을 구동합니다.

레슨 4/413개 단계

모듈 참조 API를 활용한 확장 지점은(는) CoddyKit의 무료 NestJS Enterprise Backend APIs 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 NestJS Enterprise Backend APIs 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. NestJS Enterprise Backend APIs 강의에는 총 4개의 강의가 포함되어 있습니다.

이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.

Why ModuleRef Exists

NestJS resolves dependencies declaratively: you list providers in a constructor and the container wires them. But a plugin host can't know its extensions at compile time. You need to ask the container for a provider imperatively, at runtime.

ModuleRef is the DI container's public handle. It lets you:

  • Retrieve an existing singleton by token (get).
  • Resolve a scoped (REQUEST / TRANSIENT) instance on demand (resolve).
  • Instantiate a class that was never registered as a provider (create).

This is the foundation for plugin architectures and hexagonal extension points where adapters are chosen dynamically.

Injecting ModuleRef

ModuleRef is itself an injectable. Add it to any provider's constructor and Nest hands you the reference to the module instance that owns this provider.

Note that you cannot call get() in the constructor body if the dependency isn't ready yet — prefer onModuleInit for eager lookups so the whole module tree is constructed first.

import { Injectable, OnModuleInit } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { AuditService } from './audit.service';

@Injectable()
export class PluginHost implements OnModuleInit {
  private audit: AuditService;

  constructor(private readonly moduleRef: ModuleRef) {}

  onModuleInit() {
    // Safe here: all providers are already instantiated.
    this.audit = this.moduleRef.get(AuditService);
  }
}

get() — Retrieving Singletons

moduleRef.get(token) returns an already-instantiated singleton-scoped provider. It is synchronous and returns the same instance every call.

  • By default the lookup is scoped to the current module.
  • Pass { strict: false } to search the entire application (useful when the provider lives in another module that wasn't imported here).

get() throws if the token is request- or transient-scoped — those require resolve().

// Same module — strict (default)
const local = this.moduleRef.get(LocalCache);

// Anywhere in the app graph — non-strict
const global = this.moduleRef.get(ConfigService, { strict: false });

// String / Symbol tokens work too
const driver = this.moduleRef.get<StorageDriver>('STORAGE_DRIVER', {
  strict: false,
});

resolve() — Scoped Instances

Request- and transient-scoped providers do not have a single instance, so get() can't return one. Use the asynchronous resolve() instead.

Each call to resolve() returns a brand-new transient sub-tree by default. Two calls give two different instances — important when a plugin must not share mutable state.

import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { TenantProcessor } from './tenant.processor'; // @Injectable({ scope: Scope.TRANSIENT })

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

  async run() {
    const a = await this.moduleRef.resolve(TenantProcessor);
    const b = await this.moduleRef.resolve(TenantProcessor);
    console.log(a === b); // false — distinct transient instances
  }
}

Sharing a Scoped Sub-Tree with contextId

Sometimes you want several resolve() calls to share the same request-scoped instances — for example all plugins handling one request should see the same RequestContext.

Pass a contextId to bind resolutions together. Generate one with ContextIdFactory.create() and reuse it.

import { ContextIdFactory, ModuleRef } from '@nestjs/core';

const contextId = ContextIdFactory.create();

const ctx = await this.moduleRef.resolve(RequestContext, contextId);
const svc = await this.moduleRef.resolve(ReportService, contextId);
// ctx and svc share the SAME request-scoped sub-tree

const other = await this.moduleRef.resolve(ReportService, contextId);
console.log(svc === other); // true — same contextId reuses the instance

Registering the Request Payload

When you create your own contextId outside the normal HTTP pipeline, request-scoped providers that inject REQUEST have nothing to receive. You must register the payload manually with registerRequestByContextId.

This is the key trick for running request-scoped plugins inside cron jobs, queues, or WebSocket handlers where no Express request exists.

import { ContextIdFactory, ModuleRef, REQUEST } from '@nestjs/core';

const contextId = ContextIdFactory.create();

// Inject a synthetic 'request' for this context
this.moduleRef.registerRequestByContextId({ tenantId: 'acme' }, contextId);

// Now request-scoped providers resolve correctly off the queue
const handler = await this.moduleRef.resolve(TenantHandler, contextId);
await handler.process();

create() — Instantiating Unregistered Classes

Plugins often ship classes the host never declared as providers. moduleRef.create(SomeClass) instantiates such a class while still injecting its constructor dependencies from the container.

  • The result is not cached — every call builds a fresh instance.
  • The class itself does not need an @Injectable() registration, but its dependencies must be resolvable in the module graph.

This is how you load a plugin class by name and still give it access to core services.

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

// Shipped by a third party, never in any providers array
class SlackNotifier {
  constructor(private readonly http: HttpClient) {}
  notify(msg: string) { return this.http.post('/slack', { msg }); }
}

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

  async load() {
    // http is injected from the container, SlackNotifier is not registered
    const plugin = await this.moduleRef.create(SlackNotifier);
    return plugin;
  }
}

A Token-Driven Plugin Registry

Combine a multi-provider token with ModuleRef to build an extension point. Plugins self-register under one injection token; the host resolves them and dispatches by capability.

The EXTENSIONS array is collected by Nest at boot; ModuleRef is reserved for lazy or scoped lookups the array can't express.

import { Inject, Injectable } from '@nestjs/common';

export const EXTENSIONS = Symbol('EXTENSIONS');

export interface Extension {
  readonly name: string;
  handle(event: unknown): Promise<void>;
}

@Injectable()
export class ExtensionDispatcher {
  constructor(@Inject(EXTENSIONS) private readonly exts: Extension[]) {}

  async dispatch(name: string, event: unknown) {
    const ext = this.exts.find((e) => e.name === name);
    if (!ext) throw new Error(`No extension: ${name}`);
    await ext.handle(event);
  }
}

Hexagonal Adapters Chosen at Runtime

In hexagonal design the core depends on a port (interface) and stays ignorant of adapters. ModuleRef lets you pick the concrete adapter at runtime from configuration — the classic strategy-by-token pattern.

Use non-strict get() with a string token so the adapter can live in any imported module.

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

export interface PaymentPort {
  charge(cents: number): Promise<string>;
}

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

  private adapterToken(provider: string) {
    return `PAYMENT_ADAPTER_${provider.toUpperCase()}`;
  }

  pick(provider: string): PaymentPort {
    // e.g. PAYMENT_ADAPTER_STRIPE registered in StripeModule
    return this.moduleRef.get<PaymentPort>(this.adapterToken(provider), {
      strict: false,
    });
  }
}

Modeling the Lifecycle in Plain TS

Strip away the framework and the contract is just: register adapters by key, then resolve one on demand. This standalone TypeScript program mirrors what ModuleRef.get does over a token registry — useful for unit-testing the dispatch logic in isolation.

interface PaymentPort {
  charge(cents: number): string;
}

class StripeAdapter implements PaymentPort {
  charge(cents: number): string {
    return `stripe:charged ${cents}`;
  }
}

class PaypalAdapter implements PaymentPort {
  charge(cents: number): string {
    return `paypal:charged ${cents}`;
  }
}

class Registry {
  private map = new Map<string, PaymentPort>();
  register(key: string, port: PaymentPort): void {
    this.map.set(key, port);
  }
  get(key: string): PaymentPort {
    const p = this.map.get(key);
    if (!p) throw new Error(`No adapter: ${key}`);
    return p;
  }
}

const registry = new Registry();
registry.register('stripe', new StripeAdapter());
registry.register('paypal', new PaypalAdapter());

const chosen = 'paypal';
console.log(registry.get(chosen).charge(1999));
console.log(registry.get('stripe').charge(500));

Pitfalls & Best Practices

Use ModuleRef deliberately — it is an escape hatch, not a default.

  • Don't call get() in a constructor for providers that may not exist yet; use onModuleInit.
  • Never get() a scoped provider — it throws; use resolve().
  • Remember resolve() and create() are async and uncached; awaiting in a hot path costs allocations.
  • Prefer constructor injection when the dependency is known statically — ModuleRef hides the graph from static analysis and tests.
  • Tie request-scoped resolutions to one contextId and register the request payload off-pipeline.

Quick Check

You need to obtain an instance of a REQUEST-scoped provider from a queue consumer where no HTTP request exists, and all plugins for that job must share the same request-scoped state. Which approach is correct?

Recap

You learned to resolve dependencies imperatively for plugin and hexagonal extension points:

  • get(token, { strict }) — synchronous singleton lookup, optionally across the whole app.
  • resolve(token, contextId?) — async scoped resolution; a shared contextId ties instances into one sub-tree.
  • registerRequestByContextId — injects a synthetic request so request-scoped providers work off-pipeline.
  • create(Class) — instantiates an unregistered plugin class while still injecting its dependencies.

Reach for ModuleRef only when the dependency is dynamic; prefer plain constructor injection everywhere else.

무료로 시작

AI 튜터와 함께 TypeScript을(를) 배우세요 — 무료

브라우저에서 실제 코드를 작성하고 실행하며, 24/7 AI 튜터로부터 즉각적인 도움을 받고, 웹이나 앱에서 중단한 부분부터 계속 학습하세요.

코스
20
레슨
76

자주 묻는 질문

“모듈 참조 API를 활용한 확장 지점” 강의는 무료인가요?

네 — “모듈 참조 API를 활용한 확장 지점” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 NestJS Enterprise Backend APIs 강의 전체를 잠금 해제할 수 있습니다. NestJS Enterprise Backend APIs 강의에는 총 4개의 강의가 포함되어 있습니다.

“모듈 참조 API를 활용한 확장 지점”에서 뭘 배우나요?

ModuleRef를 통해 범위가 지정된 의존성을 명령형으로 확인하여 서드파티 확장 기능을 구동합니다. 브라우저에서 직접 실행하는 실습 코드로 NestJS Enterprise Backend APIs을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

NestJS Enterprise Backend APIs을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 NestJS Enterprise Backend APIs은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.

“모듈 참조 API를 활용한 확장 지점” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 NestJS Enterprise Backend APIs 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 NestJS Enterprise Backend APIs 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 도메인 격리를 위한 포트와 어댑터
  2. DiscoveryService를 활용한 동적 공급자 등록
  3. 지연 로딩 모듈과 기능 토글
  4. 모듈 참조 API를 활용한 확장 지점
← NestJS Enterprise Backend APIs(으)로 돌아가기