NestJS 11 Enterprise API Patterns

Master NestJS 11 enterprise patterns: guards, interceptors, pipes, multi-tenancy, and production-ready API design for scalable backend systems.

E
ECOSIRE Research and Development Team
|2026年3月19日8 分で読める1.8k 語数|

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 検証パターン

ValidationPipewhitelist: 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.tsapp.enableVersioning({ type: VersioningType.URI }) を使用してこれを有効にし、@Version('1') でコントローラーを装飾します。エンタープライズ API の場合、URI バージョン管理 (/v1/contacts) が最も明示的でキャッシュに優しいアプローチです。

NestJS でファイルのアップロードを処理する最適な方法は何ですか?

ファイルのアップロードには @nestjs/platform-expressmulter とともに使用します。 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 統合、またはフルスタックのエンタープライズ プラットフォームが必要な場合でも、当社のチームは本番環境で実証済みのパターンをプロジェクトにもたらします。 開発サービスを探索 して、次のビルドを加速する方法を確認してください。

E

執筆者

ECOSIRE Research and Development Team

ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。

WhatsAppでチャット