目次を表示する

NestJS Deep Dive 2026 ─ 内部構造・再発明回避・パフォーマンスを 10 章で読み解く

NestJS の心臓 ─ DI コンテナの内部動作

第1章: NestJS の心臓 ─ DI コンテナの内部動作

DI コンテナの2 phase + REQUEST scope bubble-up

第1部の最初の章として、NestJS の最も内側にある DI コンテナを分解する。@Injectable() を付けた Service が、なぜ Controller のコンストラクタに自動で渡ってくるのか ─ その仕組みを把握すれば、Provider スコープ・循環依存・Dynamic Module・REQUEST scope の伝染まで、すべて同じ式で理解できるようになる。

NestFactory.create() の中で起きていること

const app = await NestFactory.create(AppModule);
await app.listen(3000);

この 2 行で、内部では大きく分けて以下が動く。

graph TB
    Start[NestFactory.create] --> A[1. ApplicationConfig 生成]
    A --> B[2. AbstractHttpAdapter 生成<br/>デフォルト ExpressAdapter]
    B --> C[3. NestContainer 初期化]
    C --> D[4. DependenciesScanner]
    D --> E[5. InstanceLoader]
    E --> F[6. OnModuleInit / OnApplicationBootstrap]
    F --> G[7. listen]
    style D fill:#1a2030,stroke:#ff4d6d
    style E fill:#1a2030,stroke:#b794f4

このうち4と5が DI コンテナの中核だ。

DependenciesScanner ─ メタデータを集めるフェーズ

DependenciesScanner は、まだインスタンスを作らない。やることは「モジュールツリーを再帰的に走査して、どの Provider をどの Module が持っているかを記録する」だけだ。

graph LR
    AppModule -->|imports| AuthModule
    AppModule -->|imports| UsersModule
    AppModule -->|providers| AppService
    AuthModule -->|providers| AuthService
    UsersModule -->|providers| UsersService
    UsersModule -->|providers| UsersRepository

スキャナの動き:

  1. scanForModules():モジュールツリーを再帰的に走査、imports メタデータと dynamic module プロパティをマージ
  2. scanModulesForDependencies():providers / controllers / exports を Reflect.getMetadata() で読み取り、コンテナへ登録。トポロジカルソート用に depth と global scope の bind 情報を作る

ここで重要なのは、全ての Provider が InstanceWrapper として登録されるが、コンストラクタはまだ呼ばれないこと。これが次のフェーズの自由度を生む。

InstanceLoader ─ 2 フェーズで作る

InstanceLoader は登録された InstanceWrapper を実体化する。ここがトリッキーで、2 フェーズに分かれている。

graph TB
    Phase1[Phase 1: loadPrototype]
    Phase1 --> P1[Object.create metatype.prototype]
    P1 --> P2[コンストラクタを呼ばずに<br/>プロトタイプだけ生成]
    P2 --> Phase2[Phase 2: loadInstance]
    Phase2 --> R1[resolveConstructorParams で依存解決]
    R1 --> R2[最後にコンストラクタを実行]
    style Phase1 fill:#1a2030,stroke:#ff4d6d
    style Phase2 fill:#1a2030,stroke:#b794f4

Phase 1: loadPrototype(コンストラクタを呼ばない)

const instance = Object.create(metatype.prototype);
// ↑ ここではコンストラクタは実行されない
// プロトタイプチェーンが繋がっただけのオブジェクトができる

Object.create() でクラスのプロトタイプは継承するが、コンストラクタは実行されない。これが循環依存の解決を可能にする鍵だ(後述)。

Phase 2: loadInstance(依存を解決して、実際に作る)

// 概念的なイメージ
const params = wrapper.metatype.deps;
const resolvedDeps = await Promise.all(params.map(dep => resolveDependency(dep)));
const instance = new wrapper.metatype(...resolvedDeps);

依存を先に解決してから、最後にコンストラクタを実行する。isPending / isResolved / donePromise で並行 register の競合を制御する。

Provider のスコープ ─ DEFAULT / REQUEST / TRANSIENT

Provider のスコープは 3 種類だ。

Scope挙動主な用途
DEFAULT (Singleton)アプリ全体で 1 インスタンスほぼすべての Service / Repository
REQUESTリクエストごとにインスタンス、リクエスト終了で GCリクエスト固有 state、認証コンテキスト、テナント情報
TRANSIENT注入先ごとに新インスタンス。スコープは伝染しない状態を持つユーティリティ
@Injectable() // = DEFAULT (Singleton)
export class CatsService {}

@Injectable({ scope: Scope.REQUEST })
export class TenantContextService {}

@Injectable({ scope: Scope.TRANSIENT })
export class StatefulHelper {}

REQUEST スコープの「伝染(bubble-up)」

ここが NestJS で最も重要かつ誤解されやすいポイント。

@Injectable({ scope: Scope.REQUEST })
export class TenantContextService { /* ... */ }

@Injectable() // ← DEFAULT のつもり
export class CatsService {
  constructor(private tenant: TenantContextService) {}
  // ↑ REQUEST スコープのサービスを注入した瞬間
  //   CatsService 自身も REQUEST スコープに昇格する
}

@Controller()
export class CatsController {
  constructor(private cats: CatsService) {}
  // ↑ CatsService が REQUEST なので、Controller も REQUEST に昇格
}
graph TB
    T[TenantContextService<br/>REQUEST] -->|inject| C[CatsService<br/>REQUEST に昇格]
    C -->|inject| Ctrl[CatsController<br/>REQUEST に昇格]
    style T fill:#1a2030,stroke:#ff4d6d
    style C fill:#1a2030,stroke:#ff6b35
    style Ctrl fill:#1a2030,stroke:#ff4d6d

つまり、依存元方向に「上にバブルアップ」する。30,000 並行リクエストなら、Controller・Service・Repository がそれぞれ 30,000 個ずつ作られる。NestJS 公式は「正しく設計されたアプリで約 5% のレイテンシ低下」と説明しているが、設計を誤ると桁違いに遅くなる

注意:TRANSIENT は伝染しない

Durable Providers ─ REQUEST 伝染の救済策(v9 以降)

@Injectable({ scope: Scope.REQUEST, durable: true })
export class TenantContextService { /* ... */ }

durable: true を付けて ContextIdStrategy を実装すると、テナントヘッダなどによってコンテキスト ID を共有してサブツリーを再利用できる。マルチテナント SaaS の救済策。第7章で詳しく扱う。

Custom Provider の 4 種

Provider は単純なクラス指定だけでなく、4 種類のカスタム形式がある。

// 1. useClass: 実装クラスを動的に決定
{
  provide: ConfigService,
  useClass: process.env.NODE_ENV === 'prod' ? ProdConfig : DevConfig,
}

// 2. useValue: 定数・既存インスタンス・モック
{
  provide: 'API_KEY',
  useValue: process.env.API_KEY,
}

// 3. useFactory: 関数の戻り値が provider
{
  provide: DatabaseConnection,
  useFactory: async (config: ConfigService) => {
    return await createConnection(config.get('DATABASE_URL'));
  },
  inject: [ConfigService],
}

// 4. useExisting: 既存 provider のエイリアス
{
  provide: LoggerService,
  useExisting: WinstonLoggerService,
}

useFactory の関数を async にすると Async Provider になり、起動時に Promise が解決されるまで待ってから次のフェーズへ進む。

循環依存と forwardRef

「Service A が Service B に依存し、Service B も Service A に依存する」という循環依存は、設計としては避けたいが現実には起きる。NestJS は forwardRef() で解決手段を提供する。

@Injectable()
export class CatsService {
  constructor(@Inject(forwardRef(() => DogsService)) private dogs: DogsService) {}
}

@Injectable()
export class DogsService {
  constructor(@Inject(forwardRef(() => CatsService)) private cats: CatsService) {}
}

なぜこれで解けるのか

第2フェーズの loadInstanceObject.create(metatype.prototype) を使った理由がここで効く。

sequenceDiagram
    participant L as InstanceLoader
    participant A as CatsService
    participant B as DogsService
    L->>A: loadPrototype (Object.create)
    Note over A: プロトタイプだけ作成<br/>コンストラクタ未実行
    L->>B: loadPrototype (Object.create)
    Note over B: プロトタイプだけ作成<br/>コンストラクタ未実行
    L->>A: resolveConstructorParams
    A-->>L: 依存: forwardRef(() => DogsService)
    L->>L: B のプロトタイプは既にあるので参照渡し
    L->>A: コンストラクタ実行(B のプロトタイプ参照を渡す)
    L->>B: コンストラクタ実行(A のプロトタイプ参照を渡す)

両方のプロトタイプを先に作っておくから、循環している依存も「とりあえずプロトタイプ参照を渡す」ことで解ける。コンストラクタ実行はその後。

forwardRef の落とし穴

  • 片方だけに forwardRef:両側に必須
  • コンストラクタで相手のメソッドを即呼ぶ:相手のコンストラクタはまだ完了していない可能性
  • REQUEST スコープと混ぜるundefined が混入することがある
  • そもそも循環を作らない:共有部分を別 Module に切り出す方が綺麗

ModuleRef ─ 動的に Provider を取り出す

DI コンテナを外側から触るための API。

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

  async runTask(handlerName: string) {
    // 1. Singleton を取得(同じインスタンスを返す)
    const handler = this.moduleRef.get(handlerName, { strict: false });

    // 2. TRANSIENT / REQUEST を解決(毎回新サブツリー)
    const requestScoped = await this.moduleRef.resolve(SomeService);

    // 3. 同じ context で複数解決(サブツリー共有)
    const contextId = ContextIdFactory.create();
    const a = await this.moduleRef.resolve(ServiceA, contextId);
    const b = await this.moduleRef.resolve(ServiceB, contextId);
    // a と b は同じ contextId なので、共通依存は同一インスタンス

    // 4. DI に登録されていないクラスを動的 instantiate
    const adhoc = await this.moduleRef.create(SomeClass);
  }
}

get()resolve() の使い分けが重要:

メソッド対象戻り値
get(Token)DEFAULT (Singleton) のみ同じインスタンス
resolve(Token)TRANSIENT / REQUEST毎回新サブツリー(同じ contextId 指定で共有可)

REQUEST scope を get() で取ろうとすると例外になる。これはテストコードでよく見る落とし穴。

DI コンテナのパフォーマンス特性

「正しく設計された」Singleton 中心のアプリでは、DI コンテナのコストは起動時のみ。リクエスト処理時は単に既存インスタンスを参照するだけなので、ほぼゼロ。

逆に REQUEST スコープを使うと、毎リクエストでサブツリー全体を再構築する。これがレイテンシ・GC 圧の主因になる。

graph LR
    R1[Request 1] --> S1[Sub-tree 1]
    R2[Request 2] --> S2[Sub-tree 2]
    R3[Request 3] --> S3[Sub-tree 3]
    Rn[Request N] --> Sn[Sub-tree N]
    S1 --> GC[GC 圧]
    S2 --> GC
    S3 --> GC
    Sn --> GC
    style GC fill:#1a2030,stroke:#ff4d6d

第7章の「DI スコープの罠」で、この実装上の最適化(Durable Providers、AsyncLocalStorage 代替)を深掘りする。

起動シーケンスとライフサイクルフック

NestFactory.create() のあと、以下のフックが順序通りに呼ばれる:

graph TB
    A[OnModuleInit<br/>依存先 → 依存元 leaf-first]
    A --> B[OnApplicationBootstrap<br/>全モジュール init 後]
    B --> C[listen 開始]
    C --> D[実行フェーズ]
    D --> E[SIGTERM 受信]
    E --> F[OnModuleDestroy<br/>依存元 → 依存先 root-first]
    F --> G[BeforeApplicationShutdown]
    G --> H[HTTP 接続 close]
    H --> I[OnApplicationShutdown]
    style A fill:#1a2030,stroke:#4cc9f0
    style F fill:#1a2030,stroke:#ff4d6d

v11 で重要な変更:依存チェーン A → B → C のとき、

  • OnModuleInitC → B → A(依存先から)
  • OnModuleDestroyA → B → C(依存元から)

これにより「初期化時に依存先を使い、破棄時に依存元から閉じる」という正しい順序が保証される。第4章で詳細を扱う。

Graceful Shutdown は明示的に有効化が必要

const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // ← 忘れがち、デフォルト無効
await app.listen(3000);

本章の要点

#要点
1NestJS 起動は DependenciesScanner(メタデータ収集)→ InstanceLoader(インスタンス化) の 2 段構え
2InstanceLoader は loadPrototype(コンストラクタ呼ばず)→ loadInstance(依存解決後コンストラクタ) の 2 フェーズ
3Provider スコープは DEFAULT / REQUEST / TRANSIENT の 3 種。REQUEST は依存元方向に伝染(bubble-up)
4Durable Providers(v9 以降)でテナント単位にサブツリー再利用が可能
5Custom Provider は useClass / useValue / useFactory / useExisting の 4 種
6循環依存は forwardRef() で解ける。両側必須、REQUEST と混ぜない
7ModuleRef.get() は Singleton 用、resolve() は TRANSIENT/REQUEST 用。ContextId 共有でサブツリー共有可
8Graceful Shutdown は app.enableShutdownHooks() を呼ばないと有効にならない

効いている根本原理

本章は 原理1(DI コンテナを信頼する) を根本から分解した章だった。「自分で new していないか」「DI で取れるものを取っているか」という問いに答えるには、まず DI コンテナが何をしているのかを知る必要がある。次章では、これを支える土台 ─ Decorator と Reflect.metadata の二層構造 ─ を扱う。@Injectable() がなぜ動くのか、なぜ NestJS は今でも experimentalDecorators を使い続けているのか、を見ていく。