NestJS 11 エンタープライズ API パターン
NestJS 11 は、Angular にインスピレーションを得たアーキテクチャと Node.js のパワーを組み合わせた、TypeScript で本番グレードの API を構築するための頼りになるフレームワークとして登場しました。エンタープライズ規模で運用している場合、数百万のリクエストを処理し、マルチテナント データを管理し、数十のモジュールを調整している場合、初日に選択したパターンによって、コードベースが適切に拡張されるか、それともコードベース自体の重みで崩壊するかが決まります。
このガイドでは、310 以上の TypeScript ファイルを含む 56 モジュールの NestJS 11 バックエンドの構築から苦労して得た教訓を抽出し、モジュールの構成やガード構成から、実際に負荷に耐えられるマルチテナント パターンまですべてをカバーしています。
重要なポイント
forRoutes('*')ではなくforRoutes('*path')を使用する — NestJS 11 ではワイルドカード ルート マッチングが変更されました- グローバル例外フィルターは
APP_FILTERではなくmain.tsに登録する必要があります- マルチテナントでは、ミドルウェアだけでなく、すべてのクエリ層で
organizationIdフィルタリングが必要です@Public()デコレータ パターンは、オープン ルートのガードを完全に無効にするより安全です- Drizzle クエリでは
sql.raw()を決して使用しないでください。常にパラメーター化されたsqlテンプレート リテラルを使用します。- すべての機能モジュールでの再インポートを避けるために、EmailModule は
@Global()である必要があります- 環境解決の問題を避けるために、NestJS ブートストラップの前に Dotenv を
main.tsにプリロードする必要があります- レート制限はすべてのパブリック エンドポイントで必須です —
@nestjs/throttlerを使用してください
大規模なプロジェクト構造
NestJS で最も重要なアーキテクチャ上の決定は、モジュールをどのように編成するかです。エンタープライズ規模では、app.module.ts のフラットなモジュール リストは管理できなくなります。機能するパターンは、明示的な依存関係宣言を使用したドメイン駆動型のモジュール グループ化です。
apps/api/src/
modules/
auth/
auth.module.ts
auth.controller.ts
auth.service.ts
guards/
jwt.guard.ts
roles.guard.ts
decorators/
public.decorator.ts
roles.decorator.ts
contacts/
contacts.module.ts
contacts.controller.ts
contacts.service.ts
contacts.spec.ts
dto/
create-contact.dto.ts
update-contact.dto.ts
billing/
billing.module.ts
billing.service.ts
webhook.controller.ts
shared/
filters/
global-exception.filter.ts
interceptors/
transform.interceptor.ts
pipes/
validation.pipe.ts
health/
health.controller.ts
indicators/
main.ts
app.module.ts
各ドメイン モジュールは自己完結型です。 shared/ ディレクトリには、横断的な懸念事項が含まれています。この分離により、既存のコードに触れることなく新しいドメインを追加できます。
@Public() デコレータ パターン
NestJS 11 は JWT ガードをグローバルに適用しますが、ヘルスチェック、認証コールバック、Webhook レシーバーなど、選択したエンドポイントを開く必要があります。 @Public() デコレータ パターンは、ルートごとにガードを無効にするよりもはるかに優れています。
// decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
// app.module.ts
import { APP_GUARD } from '@nestjs/core';
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
],
})
export class AppModule {}
@Public() で装飾されたコントローラー メソッドは、JWT 検証を完全にバイパスするようになりました。 Webhook コントローラー、ヘルス エンドポイント、および認証ルートはすべてこのパターンを使用します。
OrganizationId を使用したマルチテナント
マルチテナント API の基本ルール: すべてのデータベース クエリは organizationId でフィルターする必要があります。 req.organizationId を設定するミドルウェアだけでは十分ではありません。開発者がフィルターの適用を忘れると、クロステナント データが公開されてしまいます。
パターンは、JWT ペイロードから organizationId を抽出し、それを型指定されたリクエスト インターフェイスに添付することです。
// types/authenticated-request.interface.ts
import { Request } from 'express';
export interface AuthenticatedRequest extends Request {
user: {
sub: string;
email: string;
name: string;
role: 'admin' | 'support' | 'user';
organizationId: string;
};
}
// contacts/contacts.controller.ts
import { Controller, Get, Post, Body, Req } from '@nestjs/common';
import { AuthenticatedRequest } from '../../types/authenticated-request.interface';
@Controller('contacts')
export class ContactsController {
constructor(private contactsService: ContactsService) {}
@Get()
findAll(@Req() req: AuthenticatedRequest) {
return this.contactsService.findAll(req.user.organizationId);
}
@Post()
create(@Body() dto: CreateContactDto, @Req() req: AuthenticatedRequest) {
return this.contactsService.create(dto, req.user.organizationId);
}
}
// contacts/contacts.service.ts
import { db } from '@ecosire/db';
import { contacts } from '@ecosire/db/schema';
import { eq, and } from 'drizzle-orm';
@Injectable()
export class ContactsService {
async findAll(organizationId: string) {
return db
.select()
.from(contacts)
.where(eq(contacts.organizationId, organizationId))
.limit(100);
}
}
サービス層はクエリ レベルで organizationId を強制します。 SQL 自体が分離を強制するため、ミドルウェアの障害やデコレーターの忘れによっては、テナント間データが漏洩する可能性はありません。
グローバル例外フィルター
すべてのエンドポイントにわたって一貫したエラー応答を実現するには、グローバル例外フィルターが必要です。 NestJS 11 の HttpException 階層はほとんどのケースに対応しますが、予期しないエラーをキャッチし、本番環境でスタック トレースを決して公開しないようにする必要もあります。
// filters/global-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
let errors: Record<string, string[]> | undefined;
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
} else if (typeof exceptionResponse === 'object') {
const resp = exceptionResponse as Record<string, unknown>;
message = (resp.message as string) || message;
if (Array.isArray(resp.message)) {
// Validation errors from class-validator
errors = this.formatValidationErrors(resp.message as string[]);
message = 'Validation failed';
}
}
} else if (exception instanceof Error) {
this.logger.error(exception.message, exception.stack);
// Never expose stack traces in production
if (process.env.NODE_ENV !== 'production') {
message = exception.message;
}
}
response.status(status).json({
statusCode: status,
message,
errors,
path: request.url,
timestamp: new Date().toISOString(),
});
}
private formatValidationErrors(messages: string[]): Record<string, string[]> {
const errors: Record<string, string[]> = {};
for (const msg of messages) {
const [field, ...rest] = msg.split(' ');
if (!errors[field]) errors[field] = [];
errors[field].push(rest.join(' '));
}
return errors;
}
}
main.ts に登録します。
// main.ts
import { NestFactory } from '@nestjs/core';
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';
async function bootstrap() {
// CRITICAL: Load env vars before NestJS bootstraps
require('dotenv').config({ path: join(__dirname, '..', '..', '..', '.env.local') });
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new GlobalExceptionFilter());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.listen(3001);
}
NestJS 11 ルートのワイルドカードの変更
NestJS 11 の最も重大な変更の 1 つは、ワイルドカード ルート マッチングです。 NestJS 10 から移行している場合、これによりミドルウェアが静かに破壊されます。
// NestJS 10 — works
consumer.apply(LoggerMiddleware).forRoutes('*');
// NestJS 11 — use '*path' instead
consumer.apply(LoggerMiddleware).forRoutes('*path');
同じことが、Swagger セットアップと文字列ベースのルート パターンにも当てはまります。この変更は、ミドルウェアの登録、ルートの除外、およびグロブ スタイルのルート マッチングを使用する場所に影響します。
ロールベースのアクセス制御
JWT 認証以外にも、エンタープライズ API にはロールベースの承認が必要です。デコレーターとガードのパターンにより、コントローラーがクリーンに保たれます。
// decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export type Role = 'admin' | 'support' | 'user';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
// guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY, Role } from '../decorators/roles.decorator';
import { AuthenticatedRequest } from '../../types/authenticated-request.interface';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest<AuthenticatedRequest>();
const hasRole = requiredRoles.some((role) => user.role === role);
if (!hasRole) {
throw new ForbiddenException('Insufficient permissions');
}
return true;
}
}
コントローラーでの使用法:
// For top-level module controllers — one `..`
import { Roles } from '../auth/guards/roles.guard';
// For nested sub-module controllers — two `../..`
import { Roles } from '../../auth/guards/roles.guard';
@Controller('admin/contacts')
@Roles('admin', 'support')
export class AdminContactsController {}
インポート パスの深さはバグの一般的な原因です。トップレベルのモジュール コントローラーは 1 つの .. を使用します。ネストされたサブモジュール コントローラーは 2 つの ../.. を使用します。
グローバル EmailModule パターン
@Global() デコレータは、あらゆる場所で使用されるサービスの繰り返しインポートの問題を解決します。電子メールは標準的な使用例です。通知を送信するすべての機能モジュールに EmailModule をインポートする必要はありません。
// email/email.module.ts
import { Global, Module } from '@nestjs/common';
import { EmailService } from './email.service';
@Global()
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}
AppModule に一度登録します。
@Module({
imports: [
EmailModule, // Global — available everywhere
ContactsModule,
BillingModule,
// ...
],
})
export class AppModule {}
これで、どのサービスもモジュールのインポートに触れることなく EmailService を挿入できるようになりました。同じパターンは、Redis、EventBus、およびあらゆる横断的なインフラストラクチャ サービスで機能します。
レート制限パブリック エンドポイント
エンタープライズ API は毎日悪用の試みに直面しています。 NestJS の @nestjs/throttler はガード システムときれいに統合されています。
// app.module.ts
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRoot([
{ name: 'short', ttl: 1000, limit: 10 },
{ name: 'medium', ttl: 60000, limit: 100 },
]),
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
],
})
エンドポイントごとに @Throttle() をオーバーライドして、機密ルートの制限を厳しくします。
import { Throttle } from '@nestjs/throttler';
@Controller('auth')
export class AuthController {
@Post('exchange')
@Public()
@Throttle({ short: { ttl: 60000, limit: 5 } }) // 5 per minute
async exchangeCode(@Body() dto: ExchangeCodeDto) {
return this.authService.exchangeCode(dto.code);
}
}
@nestjs/terminus によるヘルスチェック
実稼働 API には、ロード バランサーのチェックと監視のためのヘルス エンドポイントが必要です。 @nestjs/terminus は、組み込みインジケーターを備えた宣言型システムを提供します。
// health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus';
import { Public } from '../auth/decorators/public.decorator';
import { DatabaseHealthIndicator } from './indicators/database.indicator';
import { RedisHealthIndicator } from './indicators/redis.indicator';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: DatabaseHealthIndicator,
private redis: RedisHealthIndicator,
) {}
@Get()
@Public()
@HealthCheck()
check() {
return this.health.check([
() => this.db.isHealthy('database'),
() => this.redis.isHealthy('redis'),
]);
}
}
Drizzle ORM のカスタム インジケーター (ターミナルには組み込みがないため):
// health/indicators/database.indicator.ts
import { Injectable } from '@nestjs/common';
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus';
import { db } from '@ecosire/db';
import { sql } from 'drizzle-orm';
@Injectable()
export class DatabaseHealthIndicator extends HealthIndicator {
async isHealthy(key: string): Promise<HealthIndicatorResult> {
try {
await db.execute(sql`SELECT 1`);
return this.getStatus(key, true);
} catch (error) {
throw new HealthCheckError('Database check failed', this.getStatus(key, false));
}
}
}
DTO 検証パターン
ValidationPipe と whitelist: true は、不明なプロパティがサービス層に到達する前に削除します。 class-transformer と組み合わせると、DTO が防御の第一線となります。
// contacts/dto/create-contact.dto.ts
import { IsString, IsEmail, IsOptional, IsEnum, MinLength, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
export enum ContactType {
INDIVIDUAL = 'individual',
COMPANY = 'company',
}
export class CreateContactDto {
@ApiProperty({ example: 'Acme Corp' })
@IsString()
@MinLength(2)
@MaxLength(255)
@Transform(({ value }) => value?.trim())
name: string;
@ApiProperty({ example: '[email protected]' })
@IsEmail()
@Transform(({ value }) => value?.toLowerCase().trim())
email: string;
@ApiPropertyOptional({ enum: ContactType })
@IsOptional()
@IsEnum(ContactType)
type?: ContactType;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
notes?: string;
}
@Transform デコレータは検証層でデータを正規化します。空白と小文字の電子メールをトリミングすると、大文字と小文字の違いによる重複レコードが防止されます。
Swagger の統合
エンタープライズ API にはドキュメントが必要です。 NestJS の Swagger モジュールはデコレータから OpenAPI 仕様を生成しますが、セットアップは意図的に行う必要があります。
// main.ts
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = new DocumentBuilder()
.setTitle('ECOSIRE API')
.setDescription('Enterprise API for ECOSIRE platform')
.setVersion('1.0')
.addBearerAuth()
.addTag('auth', 'Authentication endpoints')
.addTag('contacts', 'Contact management')
.build();
const document = SwaggerModule.createDocument(app, config);
// Only expose in non-production environments
if (process.env.NODE_ENV !== 'production') {
SwaggerModule.setup('api/docs', app, document);
}
await app.listen(3001);
}
すべてのコントローラー メソッドには @ApiOperation と @ApiResponse が必要です。
@Get(':id')
@ApiOperation({ summary: 'Get contact by ID' })
@ApiResponse({ status: 200, description: 'Contact found', type: ContactResponseDto })
@ApiResponse({ status: 404, description: 'Contact not found' })
async findOne(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
return this.contactsService.findOne(id, req.user.organizationId);
}
よくある落とし穴と解決策
落とし穴 1: モジュール間の循環依存関係
循環インポートでは、起動時に不可解なエラーが発生して NestJS がクラッシュします。修正は forwardRef() です。
@Module({
imports: [forwardRef(() => BillingModule)],
})
export class LicenseModule {}
より良い解決策: 共有ロジックを 3 番目のモジュールに抽出し、両方とも循環依存関係を持たずにインポートします。
落とし穴 2: モジュールの初期化中に環境変数を使用できない
dotenv がロードされる前にクラス コンストラクターまたはプロバイダー ファクトリで process.env.DATABASE_URL にアクセスすると、undefined が取得されます。解決策: NestJS インポートの前に、main.ts の一番上で dotenv をロードします。
// main.ts — must be FIRST
import * as path from 'path';
require('dotenv').config({ path: path.join(__dirname, '..', '..', '..', '.env.local') });
// Then NestJS imports
import { NestFactory } from '@nestjs/core';
落とし穴 3: ライフサイクル フックの非同期操作で await が欠落している
// Wrong — database connection might not be ready
@Injectable()
export class AppService implements OnModuleInit {
onModuleInit() {
this.seedDatabase(); // Not awaited!
}
}
// Correct
async onModuleInit() {
await this.seedDatabase();
}
落とし穴 4: Drizzle クエリでの sql.raw() の使用
sql.raw() はパラメータ化をバイパスし、SQL インジェクション ベクトルを開きます。常に sql テンプレート リテラルを使用してください。
// Dangerous — never do this
const result = await db.execute(sql.raw(`SELECT * FROM contacts WHERE id = '${id}'`));
// Safe — parameterized
const result = await db.execute(sql`SELECT * FROM contacts WHERE id = ${id}`);
よくある質問
NestJS 11 API でバージョン管理を処理するにはどうすればよいですか?
NestJS 11 は、すぐに使える URI バージョン管理、ヘッダー バージョン管理、メディア タイプ バージョン管理をサポートしています。 main.ts で app.enableVersioning({ type: VersioningType.URI }) を使用してこれを有効にし、@Version('1') でコントローラーを装飾します。エンタープライズ API の場合、URI バージョン管理 (/v1/contacts) が最も明示的でキャッシュに優しいアプローチです。
NestJS でファイルのアップロードを処理する最適な方法は何ですか?
ファイルのアップロードには @nestjs/platform-express を multer とともに使用します。 S3 アップロードの場合は、multer-s3 を介してカスタム ストレージ エンジンを構成します。ハンドラーを実行する前に常にファイルのタイプとサイズをパイプ レベルで検証し、クライアントが提供する MIME タイプを決して信頼せず、代わりにマジック バイトを検証します。
NestJS でデータベース トランザクションをどのように構成すればよいですか?
Drizzle ORM は db.transaction(async (tx) => { ... }) によるトランザクションをサポートします。グローバル db インスタンスではなく、トランザクション オブジェクトをサービス メソッドに渡します。マルチオペレーションのビジネス ロジック (注文の作成 + 在庫の差し引き + 電子メールの送信) の場合は、すべてをトランザクションにラップし、コミット後に .catch() を使用して電子メール送信を非ブロックにします。
ガード、ミドルウェア、インターセプターのどのような場合に使用すべきですか?
ガードは承認を処理します (このユーザーはこのリソースにアクセスできますか?)。ミドルウェアは、横断的なリクエスト変換 (ロギング、相関 ID、解析) を処理します。インターセプターは、変換、キャッシュ、メトリクスの要求と応答のサイクルをラップします。実行順序は、ミドルウェア → ガード → インターセプター (前) → パイプ → ハンドラー → インターセプター (後) → 例外フィルターです。
NestJS モジュールを単独でテストするにはどうすればよいですか?
Test.createTestingModule() を使用して、モック化された依存関係を含むテスト サンドボックスを作成します。 jest.fn() または vi.fn() を使用してサービス メソッドをモックし、データベースとは独立してコントローラーの動作をテストします。統合テストの場合は、実際のデータベース接続 (別個のテスト データベース) で @nestjs/testing を使用し、各テスト後にトランザクション ロールバックを行います。
グローバル ガードのパフォーマンスへの影響は何ですか?
グローバル ガードはすべてのリクエストで実行されるため、高速に保ちます。 JWT 検証は通常 1 ~ 5 ミリ秒です。ガードでのデータベース検索を回避します。トークンの作成中に権限を読み込み、JWT ペイロードに含めます。すべてのリクエストに対して新しいアクセス許可が必要な場合は、データベースにアクセスするのではなく、短い TTL で Redis を使用してください。
次のステップ
エンタープライズ API を大規模に構築するには、初日から適切なアーキテクチャが必要です。 ECOSIRE のエンジニアリング チームは、複雑なマルチテナント ワークフロー、Stripe 請求、ライセンス管理、AI を活用した分析を処理する 56 モジュールの NestJS 11 バックエンドを構築、運用してきました。
カスタム NestJS API、Odoo ERP 統合、またはフルスタックのエンタープライズ プラットフォームが必要な場合でも、当社のチームは本番環境で実証済みのパターンをプロジェクトにもたらします。 開発サービスを探索 して、次のビルドを加速する方法を確認してください。
執筆者
ECOSIRE Research and Development Team
ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。
関連記事
API Rate Limiting: Patterns and Best Practices
Master API rate limiting with token bucket, sliding window, and fixed counter patterns. Protect your backend with NestJS throttler, Redis, and real-world configuration examples.
Data Mesh Architecture: Decentralized Data for Enterprise
A comprehensive guide to data mesh architecture—principles, implementation patterns, organizational requirements, and how it enables scalable, domain-driven data ownership.
ECOSIRE vs Big 4 Consultancies: Enterprise Quality, Startup Speed
How ECOSIRE delivers enterprise-grade ERP and digital transformation outcomes without Big 4 pricing, overhead, or timeline bloat. A direct comparison.