第4章: Module システムと v11 の変更点
第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
| フック | 順序 |
|---|---|
OnModuleInit | C → B → A(依存先から、leaf-first) |
OnModuleDestroy | A → 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 単位が秒 → ミリ秒)
ConsoleLoggerにjson: 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 のパフォーマンス問題への正面回答になる可能性がある。注目しておきたい。
本章の要点
| # | 要点 |
|---|---|
| 1 | Module は DAG(有向非巡回グラフ)。imports が辺、循環は forwardRef でしか許されない |
| 2 | Module の encapsulation:exports に書かれていない provider は外から見えない |
| 3 | 同じ Provider を複数 Module で providers に書くと別インスタンス。共有は provide & export + import |
| 4 | Dynamic Module は forRoot / forRootAsync / forFeature / register の 4 種、ConfigurableModuleBuilder で boilerplate 削減 |
| 5 | LazyModuleLoader は Controller / Resolver / Gateway / Middleware / Global Module に使えない、ライフサイクルフックも呼ばれない |
| 6 | v11 変更:Module 解決がオブジェクト参照ベースに。同じ設定での複数 import は別インスタンス、Module を変数化して使い回す |
| 7 | v11 変更:ライフサイクルフックの順序が対称的に逆転。OnModuleInit は leaf-first、OnModuleDestroy は root-first |
| 8 | app.enableShutdownHooks() を呼ばないと SIGTERM でフックが走らない。Graceful Shutdown の絶対前提 |
| 9 | v12 ロードマップで Standard Schema 対応(Zod / Valibot / ArkType を直接 @Body() で使える)、Q3 2026 予定 |
効いている根本原理
本章は 原理1(DI コンテナ) と 原理3(リクエスト境界の層) を Module の視点で再構成した章だった。v11 の3つの変更を理解しておくと、移行時のトラブルを構造的に避けられる。
ここまでで第1部「内部構造を理解する」は完了だ。次の第2部から「再発明していないか棚卸しする」に入る。第5章で NestJS が提供する 18 のモジュールを網羅的に見ていく。