NestJSで大規模Node.jsアプリケーションを構成する
大規模なNestJSアプリを機能別に整理する方法を解説。モジュール、コントローラー、サービス、DTO検証、Guard、設定、テストと、明示的な依存関係を紹介します。
大規模なNestJSアプリケーションは、機能単位で構成するのが基本です。ドメイン(users、orders、billingなど)ごとに専用のモジュールフォルダーを用意し、その中にコントローラー、サービス、DTO、テストをまとめます。モジュール間のコード共有は、明示的なexportsとimportsを通じてのみ行います。
規模が拡大し続けるExpress APIを引き継いだことがあれば、次のような症状に覚えがあるでしょう。1つのルートファイルが認証、バリデーション、データベースへの書き込みをすべて担い、process.envがあちこちで読み込まれています。どのヘルパーなら安全に変更できるのか、誰にもわかりません。
本記事では、ユーザーと注文を扱う小さなAPIを1つ構築しながら、モジュール、コントローラー、プロバイダー、バリデーション、ガード、設定、共有モジュール、ユニットテストといった各要素を適切な場所に配置していきます。まだフレームワークを選定中であれば、JavaScriptにRailsはあるのか?で代替手段を比較しています。
重要なポイント
- NestJSのモジュールは、デフォルトでプロバイダーを外部に公開しません。他のモジュールがプロバイダーを注入できるのは、所有モジュールがそれをexportし、利用側のモジュールがそのモジュールをimportしている場合に限られます。
- 機能単位で構成すると、各ドメインのモジュール、コントローラー、サービス、DTO、テストが1つのフォルダーにまとまるため、ordersへの変更は
src/orders/の中だけで完結します。 whitelistを有効にすると、ValidationPipeはバリデーションデコレーターが付いていないプロパティを除去します。さらにforbidNonWhitelistedを追加すると、そのようなリクエストはエラーになります。- ガードが
falseを返すとNestはForbiddenExceptionをスローするため、認証チェックを各ハンドラーに書く必要がなく、1つのクラスに集約できます。 - モジュール間の循環importが解決されないと、Nestはブートストラップ時に停止します。
forwardRef()を使うよりも、共有されている概念を切り出すほうが優れた解決策です。
問題:ルールのないExpressアプリケーション
Expressはコードの配置場所についてルールを定めていないため、成長したアプリケーションは、最初のいくつかのハンドラーがたまたま書かれた形のまま構成されていきます。多くのプロジェクトは、Node.jsをTypeScriptとExpressでセットアップするガイドで説明されているように、1つのapp.ts、routesフォルダー、いくつかのミドルウェアから始まります。数か月もすると、ハンドラーは次のようになります。
app.post('/orders', (req, res) => {
if (req.headers['x-api-key'] !== process.env.API_KEY) return res.sendStatus(403);
const { userId, items } = req.body;
if (typeof userId !== 'number' || !Array.isArray(items)) return res.sendStatus(400);
const user = users.find((u) => u.id === userId);
if (!user) return res.sendStatus(404);
orders.push({ id: orders.length + 1, userId, items });
res.status(201).json(orders.at(-1));
});
このハンドラーには5つの関心事が混在しています。NestJSでは、それぞれに決まった配置場所があります。
| Expressでの慣習 | NestJSの構成要素 |
|---|---|
| ルートファイル | コントローラー |
| ビジネスロジックを含むヘルパーモジュール | プロバイダー(サービス) |
| ミドルウェアやハンドラー内の認証チェック | ガード |
process.envの読み込み | ConfigService |
req.bodyの手動チェック | DTO + バリデーションパイプ |
ただし、これにはコストが伴います。ルートが数個しかないAPIであれば、NestJSのデコレーター、モジュール、DIコンテナーは不要な儀式にすぎません。その真価が発揮されるのは、複数の開発者がコードベースの他の部分を読まずに、それぞれ1つの機能を変更するようになってからです。
大規模なNestJSプロジェクトはどう構成すべきか?
保守しやすいNestJSプロジェクト構成の核となるのは、機能ごとに1つのフォルダーを用意し、それぞれに専用のモジュールを持たせることです。このレイアウトであれば、ordersの動作を変更する際に開くのは1つのディレクトリだけで済み、controllers/、services/、dto/といった別々のツリーを探し回る必要はありません。
src/
├── users/
│ ├── dto/create-user.dto.ts
│ ├── users.controller.ts
│ ├── users.service.ts
│ ├── users.service.spec.ts
│ └── users.module.ts
├── orders/
│ ├── dto/create-order.dto.ts
│ ├── orders.controller.ts
│ ├── orders.service.ts
│ ├── orders.service.spec.ts
│ └── orders.module.ts
├── shared/
│ ├── api-key.guard.ts
│ ├── audit-log.service.ts
│ └── shared.module.ts
├── app.module.ts
└── main.ts
NestJSのモジュールは、デフォルトでプロバイダーをカプセル化します。OrdersServiceはUsersServiceを必要とするため、UsersModuleはそれをexportする必要があります。
// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller.js';
import { UsersService } from './users.service.js';
import { SharedModule } from '../shared/shared.module.js';
@Module({
imports: [SharedModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
// orders/orders.module.ts
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller.js';
import { OrdersService } from './orders.service.js';
import { UsersModule } from '../users/users.module.js';
import { SharedModule } from '../shared/shared.module.js';
@Module({
imports: [UsersModule, SharedModule],
controllers: [OrdersController],
providers: [OrdersService],
})
export class OrdersModule {}
依存関係グラフはOrdersModule → UsersModule → SharedModuleという一方向になります。UsersModuleがexportしないものはすべて内部に閉じたままとなり、これこそが機能を安全にリファクタリングできる理由です。
NestJSにおけるコントローラー、プロバイダー、依存性注入とは?
コントローラーは、HTTPルートをメソッドにマッピングします。プロバイダーは、@Injectable()が付与されたクラスのうち、Nestが生成して他のクラスに渡せるものすべてを指します。依存性注入(DI)とは、クラスが必要とするものをコンストラクターのパラメーターとして列挙し、フレームワークがそのインスタンスを供給する仕組みです。同じ仕組みにより、テストでは本物の依存関係の代わりにフェイクを渡せます。
// users/users.service.ts
import { Injectable } from '@nestjs/common';
export interface User { id: number; email: string }
@Injectable()
export class UsersService {
private readonly users: User[] = [{ id: 1, email: 'ada@example.com' }];
findOne(id: number): User | undefined {
return this.users.find((u) => u.id === id);
}
}
// orders/orders.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { UsersService } from '../users/users.service.js';
import { AuditLogService } from '../shared/audit-log.service.js';
import { CreateOrderDto } from './dto/create-order.dto.js';
@Injectable()
export class OrdersService {
private readonly orders: Array<CreateOrderDto & { id: number }> = [];
constructor(
private readonly users: UsersService,
private readonly audit: AuditLogService,
) {}
create(dto: CreateOrderDto) {
if (!this.users.findOne(dto.userId)) {
throw new NotFoundException(`User ${dto.userId} not found`);
}
const order = { id: this.orders.length + 1, ...dto };
this.orders.push(order);
this.audit.record('order.created', order.id);
return order;
}
}
// orders/orders.controller.ts
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiKeyGuard } from '../shared/api-key.guard.js';
import { CreateOrderDto } from './dto/create-order.dto.js';
import { OrdersService } from './orders.service.js';
@Controller('orders')
@UseGuards(ApiKeyGuard)
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Post()
create(@Body() dto: CreateOrderDto) {
return this.orders.create(dto);
}
}
コントローラーは、リクエストを1回のサービス呼び出しに変換するだけです。「ユーザーが存在しなければならない」というルールはサービス内に置かれるため、他の呼び出し元からも再利用でき、テストからも検証できます。NotFoundExceptionなどの組み込みHTTP例外は、対応するエラーレスポンスに変換されます。
NestJSのDTOとバリデーションパイプはどのように機能するのか?
DTO(データ転送オブジェクト)は、リクエストボディの形状を記述するクラスです。バリデーションパイプと組み合わせることで、コントローラーが実行される前に不正な入力を拒否できます。サンプルのペイロードがあれば、JSON to TypeScriptコンバーターで元となる型を生成し、そこにclass-validatorのデコレーターを追加していきます。
// orders/dto/create-order.dto.ts
import { ArrayNotEmpty, IsArray, IsInt, IsString } from 'class-validator';
export class CreateOrderDto {
@IsInt()
userId!: number;
@IsArray()
@ArrayNotEmpty()
@IsString({ each: true })
items!: string[];
}
パイプはmain.tsで一度だけ登録します。
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.listen(3000);
}
bootstrap();
各オプションの動作はバリデーションのドキュメントで定義されています。whitelistはデコレーターのないプロパティを除去します。forbidNonWhitelistedは、その暗黙的な除去をエラーレスポンスに変えます。transformはペイロードをDTOクラスのインスタンスに変換します。ValidationPipeはclass-validatorとclass-transformerに依存しています。
2026年8月にリリースされたNestJS 12では、2つ目のアプローチとしてStandardSchemaValidationPipeが追加されました。これは@Body({ schema })として渡されたZod、Valibot、ArkTypeのスキーマでバリデーションを行うものです。schemaオプション単体では、パラメーターにスキーマを記録するだけです。実際のバリデーションは、たとえばapp.useGlobalPipes(new StandardSchemaValidationPipe())のようにパイプを登録した時点で行われるようになります。
パイプが登録されていなければ、フロントエンドがそれを教えてくれます。APIを利用するUIのセッションリプレイでは、ユーザーの入力、不正な形式のリクエスト、汎用的なエラートーストが並んで表示されるため、修正すべき箇所がDTOの境界であることが明確になります。
ガードと設定で認証と環境変数をどう扱うか?
ガードは、CanActivateを実装した@Injectable()クラスです。ルートハンドラーの前に実行され、リクエストを続行させるかどうかを判断します。falseを返すと、NestはForbiddenException(403)をスローします。@nestjs/configと組み合わせれば、ガードはシークレットをprocess.envではなくConfigServiceから読み込めます。
// shared/api-key.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(private readonly config: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
return request.headers['x-api-key'] === this.config.get<string>('API_KEY');
}
}
request.headersの読み込みは、ExpressとFastifyのどちらのアダプターでも動作します。AppModuleでは起動時に環境変数をバリデーションし、キーが欠けている場合に最初のリクエスト時ではなく起動時に失敗するようにします。NestJS 12以降、validationSchemaはZodスキーマなど、任意のStandard Schemaオブジェクトを受け付けます。それ以前のバージョンではJoiスキーマが必要でした。
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { z } from 'zod';
import { UsersModule } from './users/users.module.js';
import { OrdersModule } from './orders/orders.module.js';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema: z.object({ API_KEY: z.string().min(1) }),
}),
UsersModule,
OrdersModule,
],
})
export class AppModule {}
ほぼすべてのモジュールが参照するため、グローバルフラグが真価を発揮するのは設定だけといえます。
共有モジュールと依存関係を差し替えたテスト
複数の機能で使われるコードは、それをexportする共有モジュールに配置します。各機能はそのモジュールをimportするため、すべての依存関係がimports配列に明示されます。モジュールに@Global()を付けると、この結びつきが見えなくなってしまいます。
// shared/shared.module.ts
import { Module } from '@nestjs/common';
import { AuditLogService } from './audit-log.service.js';
import { ApiKeyGuard } from './api-key.guard.js';
@Module({
providers: [AuditLogService, ApiKeyGuard],
exports: [AuditLogService, ApiKeyGuard],
})
export class SharedModule {}
DIの効果はテストで発揮されます。Test.createTestingModuleは小さなモジュールを構築し、その中で本物のプロバイダーをプレーンなオブジェクトに置き換えられます。
// orders/orders.service.spec.ts
import { Test } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { OrdersService } from './orders.service.js';
import { UsersService } from '../users/users.service.js';
import { AuditLogService } from '../shared/audit-log.service.js';
describe('OrdersService', () => {
it('throws when the user does not exist', async () => {
const moduleRef = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: UsersService, useValue: { findOne: () => undefined } },
{ provide: AuditLogService, useValue: { record: () => {} } },
],
}).compile();
const service = moduleRef.get(OrdersService);
expect(() => service.create({ userId: 99, items: ['book'] })).toThrow(NotFoundException);
});
});
NestJS 12以降、nest newはESMプロジェクトにはVitestを、CommonJSプロジェクトにはJestをセットアップします。Vitestを使う場合、globalsを有効にしていなければ、describe、it、expectをvitestからimportしてください。実際のデータベースを使った結合テストについては、Docker ComposeでNestJSを実行するでセットアップ方法を解説しています。
大規模なNestJSコードベースでよくある間違いとは?
大規模なNestJSアプリケーションにおける構造上の問題の多くは、次の3つのパターンに起因します。
- モジュールの循環依存。
UsersModuleとOrdersModuleが互いをimportしていると、Nestはモジュールインスタンスを生成できず、ブートストラップ時に失敗します。forwardRef()で循環は解決できますが、共有されている概念を独立したモジュールに移せば、循環そのものがなくなります。 - 肥大化した
AppModule。 ルートモジュールは機能モジュールとグローバル設定をimportするだけにとどめ、数十ものコントローラーやプロバイダーを自ら宣言すべきではありません。 - コントローラー内のロジック。 コントローラーが判断を下すようになると、そのルールは再利用できず、HTTPを介さずにユニットテストすることもできなくなります。コントローラーの各メソッドは、1回のサービス呼び出しにとどめましょう。
まとめ
大規模なNestJSアプリケーションを管理しやすい状態に保つには、各機能を専用のモジュールに配置し、モジュール間のつながりを明示的なexportのみに限定し、コントローラーを薄く保つことが重要です。まずは現在のAPIからPOST /ordersのようなルートを1つ選んでください。そのルールをサービスに、ボディのチェックをDTOに、認証をガードに移し、このパターンを1機能ずつ繰り返していきましょう。
よくある質問
NestJSの認証ロジックはミドルウェアとガードのどちらに置くべきですか?
ルート固有の認証はガードに置いてください。ミドルウェアはNestがルートハンドラーを選択する前に実行されるため、参照できるのは生のリクエストとレスポンスだけです。コントローラー、ハンドラー、デコレーターのメタデータは参照できません。ガードはすべてのミドルウェアが完了した後、インターセプターやパイプよりも前に実行され、ハンドラーとそのクラスにアクセスできるExecutionContextを受け取ります。ロギングのようにすべてのリクエストに適用する処理には、引き続きミドルウェアが適しています。
Nest can't resolve dependenciesエラーの原因は何ですか?
このエラーは、メッセージに示されたモジュール内で、コンストラクターのパラメーターのいずれかに対応するプロバイダーをNestが見つけられないことを意味します。通常、そのプロバイダーがモジュールのproviders配列に含まれておらず、imports配列内のどのモジュールからもexportされていないことが原因です。解決するには、プロバイダーを所有するモジュールからそれをexportし、そのモジュールをimportしてください。また、@Injectable()が付与されたクラスがimports配列に入っていないかも確認してください。
transformを有効にしているのに、リクエストボディ内の数値文字列が@IsIntで失敗するのはなぜですか?
transformをtrueに設定すると、ValidationPipeはJSONボディをDTOクラスのインスタンスに変換しますが、プリミティブ値の型までは変更しません。'30'として送信されたuserIdは文字列のままとなり、@IsIntで失敗します。クライアントから実際の数値を送信するか、transformOptions内でenableImplicitConversionをtrueに設定してください。ただし暗黙的な変換にも落とし穴があり、boolean型のフィールドでは'false'を含む空でない文字列がすべてtrueになります。
NestJSのDTOでネストされたオブジェクトやオブジェクトの配列をバリデーションするにはどうすればよいですか?
プロパティに、class-validatorの@ValidateNested()とclass-transformerの@Type(() => ItemDto)を追加してください。@Typeがないと、ネストされた値はプレーンなオブジェクトのままとなり、ItemDtoのバリデーションデコレーターは一切実行されません。オブジェクトの配列の場合は、@ValidateNested({ each: true })を同じ@Typeデコレーターと組み合わせて使用します。ネストされるDTOクラスにも独自のclass-validatorデコレーターが必要で、whitelistはそのプロパティにも適用されます。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k