目次を表示する

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

Module システムと v11 の変更点

第4章: Module システムと v11 の変更点

v11 の 3 変更点 + Graceful Shutdown

第1〜3章で DI コンテナ・Decorator・リクエストライフサイクルを見た。本章ではこれらを束ねるModule システムを扱う。さらに NestJS v11(2025-01-20 リリース)で起きた3つの大きな変更を確認する。v10 → v11 の変更を理解しないと、踏むはずのなかった地雷を踏む可能性がある。

Module は DAG(有向非巡回グラフ)

NestJS の Module は imports を辺とする有向グラフだ。NestJS の DependenciesScanner がこれを走査し、depth を計算してトポロジカルに instantiation を行う。

graph TB
    App[AppModule] --> Auth[AuthModule]
    App --> Users[UsersModule]
    App --> Logging[LoggingModule]
    Auth --> Common[CommonModule]
    Users --> Common
    Users --> Database[DatabaseModule]
    Auth --> Database
    style App fill:#1a2030,stroke:#4cc9f0
    style Common fill:#1a2030,stroke:#b794f4
    style Database fill:#1a2030,stroke:#ff4d6d

循環は forwardRef でしか許されない(実質 DAG 前提)。「AuthModule が UsersModule に依存し、UsersModule が AuthModule に依存する」のような循環があれば、共通部分を別 Module に切り出すのが筋。

Module の encapsulation ─ exports しないと外から見えない

@Module({
  providers: [CatsService, CatsHelper], // ← 内部利用
  exports: [CatsService],                 // ← 他 Module から使える
})
export class CatsModule {}

// 別 Module から
@Module({
  imports: [CatsModule],
})
export class AppModule {
  // ✅ CatsService は使える
  // ❌ CatsHelper は使えない(exports に書かれていない)
}

これが Module の encapsulation。私的な Provider と公開 Provider を分ける仕組み。

Singleton 性の落とし穴

「同じ Provider を複数 Module で providers に書く」と、Module ごとに別インスタンスになる。

// ❌ 同じ MyService が2つできる
@Module({ providers: [MyService] })
export class A {}

@Module({ providers: [MyService] })
export class B {}

// ✅ 1つの Module で provide & export、他は import
@Module({ providers: [MyService], exports: [MyService] })
export class SharedModule {}

@Module({ imports: [SharedModule] })
export class A {}

@Module({ imports: [SharedModule] })
export class B {}
// → A と B で同じ MyService インスタンス

Global Module ─ 全モジュールから参照可能

@Global()
@Module({
  providers: [ConfigService],
  exports: [ConfigService],
})
export class ConfigModule {}

@Global() を付けた Module の exports は、import せずに全モジュールから参照可能になる。便利だが乱用すると Module 間の依存が見えなくなる ─ ConfigModule のような共通インフラ系に限定するのが定石。

Dynamic Module ─ 設定可能なモジュール

「設定値を渡してモジュールを構築する」パターン。forRoot / forRootAsync / forFeature / register の 4 種が定石。

// register: consumer ごとに独立した設定(HttpModule 等)
HttpModule.register({ timeout: 5000 });

// forRoot: アプリ全体で1度設定(GraphQL / TypeORM)
TypeOrmModule.forRoot({ type: 'postgres', /* ... */ });

// forFeature: forRoot の前提のもとに module-specific な追加(リポジトリ登録など)
TypeOrmModule.forFeature([Cat, Owner]);

// 全部に Async 版(useFactory / useClass / useExisting)
ConfigModule.forRootAsync({
  useFactory: async () => ({ /* config */ }),
  inject: [/* dependencies */],
});

ConfigurableModuleBuilder(v9 以降)

Dynamic Module の boilerplate を削減するヘルパ。

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

export interface MyModuleOptions {
  apiKey: string;
  timeout?: number;
}

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
  new ConfigurableModuleBuilder<MyModuleOptions>().build();

@Module({})
export class MyModule extends ConfigurableModuleClass {}

// 使う側
MyModule.register({ apiKey: '...', timeout: 5000 });
MyModule.registerAsync({ useFactory: () => ({ apiKey: '...' }) });

forRoot / forRootAsync / register / registerAsync自動生成される。手書きするより安全。

Lazy Module ─ 起動を速くしたいときに

LazyModuleLoader で Module を遅延読み込みできる。

@Injectable()
export class TaskRunner {
  constructor(private lazyModuleLoader: LazyModuleLoader) {}

  async run(taskName: string) {
    const moduleRef = await this.lazyModuleLoader.load(() =>
      import('./tasks/heavy-task.module').then(m => m.HeavyTaskModule)
    );
    const service = moduleRef.get(HeavyTaskService);
    return service.execute();
  }
}
性質内容
初回ロード数 ms
2回目以降キャッシュで 0.x ms

Lazy Module の制限事項

lazy load できないもの

  • Controller:ルートが起動時に確定する
  • Resolver / Gateway:GraphQL / WebSocket 同様
  • Middleware:登録順が確定する
  • Global Module:全 Module から見えるべき性質
  • Global enhancer(APP_GUARD 等)

加えて、Lazy module ではライフサイクルフックが呼ばれない。注意。

★ v11 の主要変更点(2025-01-20 リリース)

NestJS v11 は 2025-01-20 にリリースされた。本記事執筆時の最新は v11.1.x 系。v10 からの主要変更は以下。

変更1:Module 解決アルゴリズムの刷新

v10 まで「モジュールメタデータをハッシュ化して同一性判定」していたが、v11 から オブジェクト参照ベースに変更された。

これが意味するのは:

// v10 の挙動
@Module({
  imports: [
    ConfigModule.forRoot({ apiKey: 'X' }), // 設定 X
    ConfigModule.forRoot({ apiKey: 'X' }), // 同じ設定 X
  ],
})
// → ハッシュ化で同一とみなされ、1 つの Module インスタンス

// v11 の挙動
// → オブジェクト参照が違うので、別 Module インスタンス(!!)

同じ設定で複数回 import すると別インスタンスになる」。共有したい場合は:

// ✅ Module を変数に格納して使い回す
const sharedConfigModule = ConfigModule.forRoot({ apiKey: 'X' });

@Module({
  imports: [sharedConfigModule, sharedConfigModule], // ← 同じ参照
})

これは 起動時間短縮を狙った変更

変更2:ライフサイクルフックの順序が対称的に逆転

v11 で最も実害につながりやすい変更。依存チェーン A → B → C(A が B に依存、B が C に依存)のとき:

graph LR
    subgraph 初期化
        A1[OnModuleInit] --> C1[C]
        C1 --> B1[B]
        B1 --> A2[A]
    end
    subgraph 終了
        A3[OnModuleDestroy] --> A4[A]
        A4 --> B2[B]
        B2 --> C2[C]
    end
    style A1 fill:#1a2030,stroke:#4cc9f0
    style A3 fill:#1a2030,stroke:#ff4d6d
フック順序
OnModuleInitC → B → A(依存先から、leaf-first)
OnModuleDestroyA → B → C(依存元から、root-first)

これが対称的になったことで、「初期化時に依存先を使い、破棄時に依存元から閉じる」という正しい順序が保証される。

実害が出やすいケース:

// ❌ v10 で動いていたが、v11 で順序が変わって動かないコード
@Injectable()
export class CacheService implements OnModuleDestroy {
  async onModuleDestroy() {
    // v10:依存先(DBService)が先に destroy されているかも
    // v11:依存元(CacheService)が先に destroy される
    await this.flushAllToDb(); // ← DBService がまだ生きていることが保証される
  }
}

v11 では「自分が破棄されるとき、自分の依存先はまだ生きている」ことが保証される。これは正しい順序。

変更3:Global module の middleware が最初に実行

v10 では middleware の登録順が依存グラフに引きずられていたが、v11 からは「Global module の middleware が、依存グラフ上の位置に関係なく最初に実行される」。

これにより、Logger / RequestId 生成 / CORS のような最初に走るべき横断関心事を Global module に置くと、確実に最初に動く。

その他の変更

  • Express v5 デフォルト:path matching が変更(@Get('users/*')@Get('users/*splat'))。本記事は Express v5 前提
  • Node.js v20+ 必須:v18 EOL 2025-04
  • Reflector#getAllAndMergeオブジェクト直返し(v10 までは単一要素配列)
  • Reflector#getAllAndOverride の戻り値型が T | undefined
  • CacheModule が cache-manager v6 + Keyv ベースに刷新(TTL 単位が秒 → ミリ秒
  • ConsoleLoggerjson: true 設定オプション
  • @nestjs/cqrs が request-scoped provider と型付けコマンド/イベント/クエリをサポート

起動シーケンス全体

第1章で触れた起動シーケンスを、Module の文脈で再確認する。

graph TB
    A[NestFactory.create] --> B[DependenciesScanner<br/>Module ツリー走査]
    B --> C[InstanceLoader<br/>2 phase で instantiate]
    C --> D[OnModuleInit<br/>leaf-first]
    D --> E[OnApplicationBootstrap]
    E --> F[app.listen 開始]
    F --> G[実行フェーズ]
    G -->|SIGTERM| H[OnModuleDestroy<br/>root-first]
    H --> I[BeforeApplicationShutdown]
    I --> J[HTTP / transport close]
    J --> K[OnApplicationShutdown]
    style D fill:#1a2030,stroke:#4cc9f0
    style H fill:#1a2030,stroke:#ff4d6d

★ Graceful Shutdown を正しく動かす

これがNestJS v11 で最も重要な運用ポイント

const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();  // ← 必須、デフォルト無効
await app.listen(process.env.PORT ?? 3000);

enableShutdownHooks() を呼ばないと、SIGTERM / SIGINT を受信してもライフサイクルフックが走らない

シグナル対応
SIGINT
SIGBREAK✅(Windows)
SIGHUP✅(一部 Windows)
SIGTERM✅(Unix)

注意:Windows では SIGTERM 非対応(Windows のタスクマネージャは無条件にプロセス終了するため)。

K8s での Graceful Shutdown 実装パターン

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableShutdownHooks();
  await app.listen(3000);
}

@Injectable()
export class HealthService implements BeforeApplicationShutdown {
  private isShuttingDown = false;

  async beforeApplicationShutdown(signal: string) {
    this.isShuttingDown = true;
    // readiness probe を 503 に
    // K8s は新しいリクエストを送らなくなる
    // この時点で in-flight のリクエストはまだ処理する
  }

  // /health/readiness で参照される
  isReady() {
    return !this.isShuttingDown;
  }
}

K8s の terminationGracePeriodSeconds(デフォルト 30 秒)と整合させる。

v12 ロードマップ(参考)

NestJS v12 は Q3 2026 予定。主な内容(PR #16391 から推測される範囲):

  • 完全 ESM 移行:CommonJS から ESM へ。Node.js の require(esm) 対応が決め手
  • Test:Jest → Vitest
  • Lint:ESLint → oxlint
  • Bundle:Webpack → Rspack
  • Standard Schema 対応@Body() / @Query() / @Param()Zod・Valibot・ArkType を直接使える
  • NATS v3 対応、Express の graceful shutdown 強化、WebSocket disconnect reason

特に Standard Schema 対応は、第8章で扱う class-validator のパフォーマンス問題への正面回答になる可能性がある。注目しておきたい。

本章の要点

#要点
1Module は DAG(有向非巡回グラフ)。imports が辺、循環は forwardRef でしか許されない
2Module の encapsulation:exports に書かれていない provider は外から見えない
3同じ Provider を複数 Module で providers に書くと別インスタンス。共有は provide & export + import
4Dynamic Module は forRoot / forRootAsync / forFeature / register の 4 種、ConfigurableModuleBuilder で boilerplate 削減
5LazyModuleLoader は Controller / Resolver / Gateway / Middleware / Global Module に使えない、ライフサイクルフックも呼ばれない
6v11 変更:Module 解決がオブジェクト参照ベースに。同じ設定での複数 import は別インスタンス、Module を変数化して使い回す
7v11 変更:ライフサイクルフックの順序が対称的に逆転。OnModuleInit は leaf-first、OnModuleDestroy は root-first
8app.enableShutdownHooks() を呼ばないと SIGTERM でフックが走らない。Graceful Shutdown の絶対前提
9v12 ロードマップで Standard Schema 対応(Zod / Valibot / ArkType を直接 @Body() で使える)、Q3 2026 予定

効いている根本原理

本章は 原理1(DI コンテナ)原理3(リクエスト境界の層) を Module の視点で再構成した章だった。v11 の3つの変更を理解しておくと、移行時のトラブルを構造的に避けられる。

ここまでで第1部「内部構造を理解する」は完了だ。次の第2部から「再発明していないか棚卸しする」に入る。第5章で NestJS が提供する 18 のモジュールを網羅的に見ていく。