mcpField
N
nestjs-expert

nestjs-expert

github.com/shipshitdev開発3989 回インストール1 日前に更新
リポジトリ

nestjs-expert について

nestjs-expertは、Prisma、Postgres、厳格なTypeScriptを用いたNestJSアプリの構築・保守向けスキルです。アーキテクチャ、バリデーション、データベース、認証、性能を扱い、NestJS 12とPrisma 7固有の手法も示します。

主な機能

  1. 01NestJSのモジュールと依存性注入パターン
  2. 02DTOバリデーションとコントローラーの指針
  3. 03Prisma 7とPostgresの実践方法
  4. 04認証とガードのパターン
  5. 05クエリ性能とトランザクションの規則

活用シーン

  1. 01NestJSの機能モジュールを構成する
  2. 02DTOバリデーションとコントローラー境界を確認する
  3. 03Prisma 7とPostgresを設定する
  4. 04安全でないクエリや依存書き込みを避ける

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/shipshitdev/skills --skill nestjs-expert

インストール後、Claude Code で/nestjs-expertを入力して読み込まれたか確認します。

同梱ファイルplugin.jsonreferences/full-guide.md

詳細

NestJS Expert

スタック:NestJS 12 + Prisma 7 + Postgres + TypeScript strict mode。

NestJS 12 と Better Auth は ESM 専用パッケージを提供します。CommonJS ビルド(module: commonjs と moduleResolution: bundler の組み合わせ)も、Node 22.12 以降であれば require(esm) を通じて ESM を読み込めるため、API は Node >= 22.12 で実行してください。それ以前の Node では、起動時に ERR_REQUIRE_ESM で失敗します。バージョン 11 はレガシー系統(legacy dist-tag)です。11 と 12 のパッケージを混在させないでください。

モジュールアーキテクチャ

すべての機能を自己完結型のモジュールにします。モジュール間で直接インポートせず、エクスポートされたプロバイダーを使用してください。

src/
├── app.module.ts           # Root — imports feature modules only
├── common/                 # Shared guards, pipes, filters, interceptors
│   ├── filters/
│   ├── guards/
│   ├── interceptors/
│   └── pipes/
├── config/                 # ConfigModule setup
├── prisma/                 # PrismaModule + PrismaService (global, one client)
└── {feature}/
    ├── {feature}.module.ts
    ├── {feature}.controller.ts
    ├── {feature}.service.ts
    ├── dto/
    │   ├── create-{feature}.dto.ts
    │   └── update-{feature}.dto.ts
    └── {feature}.types.ts

依存性注入のルール

  • 可能な場合は具象クラスではなく、インターフェースを注入します
  • リクエストスコープが必要な場合を除き、@Injectable({ scope: Scope.DEFAULT })(シングルトン)を使用します
  • 循環依存はアーキテクチャ上の問題です。最終手段としてのみ forwardRef で解決してください
  • Test.createTestingModule でテストし、外部サービスは必ずモックします

コントローラー

@Controller('resources')
@UseGuards(JwtAuthGuard)
@UseInterceptors(ResponseTransformInterceptor)
export class ResourceController {
  constructor(private readonly resourceService: ResourceService) {}

  @Get()
  async findAll(@Query() query: PaginationQueryDto) {
    return this.resourceService.findAll(query);
  }

  @Post()
  @HttpCode(HttpStatus.CREATED)
  async create(@Body() dto: CreateResourceDto, @CurrentUser() user: AuthUser) {
    return this.resourceService.create(dto, user.id);
  }
}

ルール:

  • コントローラーは薄く保ちます。ビジネスロジックや DB 呼び出しは含めません
  • @Body()、@Query()、@Param() には必ず DTO の型を指定します
  • @Req() は使用せず、カスタムデコレーター @CurrentUser() を使用します

DTO とバリデーション

import { IsString, IsEnum, IsOptional, MinLength, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';

export class CreateResourceDto {
  @IsString()
  @MinLength(1)
  @MaxLength(255)
  name: string;

  @IsEnum(ResourceStatus)
  status: ResourceStatus;

  @IsOptional()
  @IsString()
  @Transform(({ value }) => value?.trim())
  description?: string;
}

main.ts にグローバルバリデーションパイプを設定します。

app.useGlobalPipes(new ValidationPipe({
  whitelist: true,        // strip unknown props
  forbidNonWhitelisted: true,
  transform: true,        // auto-transform primitives
  transformOptions: { enableImplicitConversion: true },
}));

Prisma / Postgres

スキーマは prisma/schema.prisma に配置します。クライアントをラップするグローバルな PrismaService を 1 つ用意します。機能サービスは PrismaService に依存し、コントローラーから直接触れることはありません。

Prisma 7 では、prisma-client ジェネレーターに明示的な output が必要です(PrismaClient は @prisma/client ではなく、そのパスからインポートします)。クライアントにはドライバーアダプター(@prisma/adapter-pg)が必要で、接続 URL は prisma.config.ts に配置します。CommonJS の Nest ビルドでは、ジェネレーターブロックに moduleFormat = "cjs" を設定します。output はスキーマファイルを基準に解決されるため、prisma/schema.prisma で ../src/generated/prisma を指定すると、クライアントは src/generated/prisma に出力されます。以下のサービスは src/prisma/ からインポートします。このフォルダーは git の対象外にし、CI と Docker のビルドで再生成してください。

prisma.config.ts では env("DATABASE_URL") ではなく、datasource: { url: process.env.DATABASE_URL } を設定してください。変数がない場合、env() はエラーを投げるため、.env がないクリーンな CI や Docker のビルドで prisma generate が失敗します。URL がない場合、migrate コマンドは引き続き明確なエラーで失敗します。

// src/prisma/prisma.service.ts
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  constructor() {
    super({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
  }

  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}
// prisma/schema.prisma
generator client {
  provider     = "prisma-client"
  output       = "../src/generated/prisma"
  moduleFormat = "cjs"
}

datasource db {
  provider = "postgresql"
}

model Resource {
  id        String         @id @default(uuid()) @db.Uuid
  name      String
  status    ResourceStatus @default(ACTIVE)
  userId    String         @db.Uuid
  user      User           @relation(fields: [userId], references: [id])
  createdAt DateTime       @default(now())
  updatedAt DateTime       @updatedAt
  deletedAt DateTime?

  @@index([userId, createdAt(sort: Desc)])
}

enum ResourceStatus {
  ACTIVE
  ARCHIVED
}
// service
@Injectable()
export class ResourceService {
  constructor(private readonly prisma: PrismaService) {}

  async findAll(userId: string, query: PaginationQueryDto) {
    const { page = 1, limit = 20 } = query;
    return this.prisma.resource.findMany({
      where: { userId, deletedAt: null },
      orderBy: { createdAt: 'desc' },
      skip: (page - 1) * limit,
      take: limit,
      select: { id: true, name: true, status: true, createdAt: true },
    });
  }
}

ルール:

  • ID は String(uuid または cuid)とし、リレーションは明示的な @relation フィールドで定義します。外部キーのない ID 文字列を手作業で作成しないでください
  • サービス層では ID を string 型にします。Prisma が生成する型を信頼できる情報源とし、モデルのインターフェースを手書きしないでください
  • 必要なフィールドだけを返すために select を使用します。秘密情報を含む場合は、生の行を返さないでください
  • 複数ステップの書き込みには prisma.$transaction を使用します。依存する書き込みをトランザクションなしで連結しないでください
  • 型付きクライアントを優先します。$queryRaw にはタグ付きテンプレート(Prisma.sql)を使用して、値をパラメーター化してください。ユーザー入力に対して $queryRawUnsafe を使用しないでください
  • 論理削除には deletedAt DateTime? を使用し、ユーザーデータを物理削除しないでください。読み取り時には deletedAt: null でフィルタリングします
  • スキーマの変更は prisma migrate dev / prisma migrate deploy を通して行い、データベース上で手作業で編集しないでください

認証パターン

// JWT strategy
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.get<string>('JWT_SECRET'),
      ignoreExpiration: false,
    });
  }

  async validate(payload: JwtPayload): Promise<AuthUser> {
    // return value is injected as req.user
    return { id: payload.sub, email: payload.email };
  }
}

// custom decorator
export const CurrentUser = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => ctx.switchToHttp().getRequest().user,
);

ガード

@Injectable()
export class ResourceOwnerGuard implements CanActivate {
  constructor(private readonly resourceService: ResourceService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const { user, params } = context.switchToHttp().getRequest();
    const resource = await this.resourceService.findById(params.id);
    return resource?.userId === user.id;
  }
}

例外フィルター

@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>();

    if (exception instanceof HttpException) {
      return response.status(exception.getStatus()).json({
        statusCode: exception.getStatus(),
        message: exception.message,
      });
    }

    this.logger.error('Unhandled exception', exception instanceof Error ? exception.stack : exception);
    return response.status(500).json({ statusCode: 500, message: 'Internal server error' });
  }
}

設定

// config/app.config.ts
export default registerAs('app', () => ({
  port: parseInt(process.env.PORT ?? '3000', 10),
  jwtSecret: process.env.JWT_SECRET,
  databaseUrl: process.env.DATABASE_URL,
}));

// access in service
constructor(private config: ConfigService) {}
const port = this.config.get<number>('app.port');

設定ファイル以外で process.env を直接使用しないでください。

パフォーマンスのルール

  • 読み取りクエリでは必要な列だけを select します
  • where フィルターまたは orderBy で使うすべてのフィールドに @@index を追加します
  • 複数フィールドのクエリには複合インデックスを使用します:@@index([userId, createdAt(sort: Desc)])
  • N+1 を避けるため、include / select でリレーションを取得するか、findMany({ where: { id: { in: ids } } }) で一括取得します
  • skip / take でページネーションを行うか、大きなテーブルではカーソルページネーションを使用します
  • コストの高い読み取りには @nestjs/cache-manager(Redis)でキャッシュします

よくある間違い

誤り正しい方法
コントローラーにビジネスロジックを記述するサービスに移動する
どこでもany型を使うインターフェース/DTOを定義する
console.lognew Logger(ClassName.name)
req.userを直接使う@CurrentUser()デコレーター
環境変数をハードコーディングするConfigService
findMany()ですべての列を取得する呼び出し元が必要とするものだけをselectする
ユーザー入力に$queryRawUnsafeを使う型付きクライアントまたはタグ付きの$queryRaw
トランザクションなしで依存する書き込みを行うprisma.$transaction

関連スキル

  • nestjs-queue-architect — BullMQの非同期ジョブパターン
  • postgres-ops — マイグレーション、インデックス、クエリプラン
  • error-handling-expert — グローバルなエラー戦略

コメント