nestjs-expert について
nestjs-expertは、Prisma、Postgres、厳格なTypeScriptを用いたNestJSアプリの構築・保守向けスキルです。アーキテクチャ、バリデーション、データベース、認証、性能を扱い、NestJS 12とPrisma 7固有の手法も示します。
主な機能
- 01NestJSのモジュールと依存性注入パターン
- 02DTOバリデーションとコントローラーの指針
- 03Prisma 7とPostgresの実践方法
- 04認証とガードのパターン
- 05クエリ性能とトランザクションの規則
活用シーン
- 01NestJSの機能モジュールを構成する
- 02DTOバリデーションとコントローラー境界を確認する
- 03Prisma 7とPostgresを設定する
- 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.log | new Logger(ClassName.name) |
req.userを直接使う | @CurrentUser()デコレーター |
| 環境変数をハードコーディングする | ConfigService |
findMany()ですべての列を取得する | 呼び出し元が必要とするものだけをselectする |
ユーザー入力に$queryRawUnsafeを使う | 型付きクライアントまたはタグ付きの$queryRaw |
| トランザクションなしで依存する書き込みを行う | prisma.$transaction |
関連スキル
nestjs-queue-architect— BullMQの非同期ジョブパターンpostgres-ops— マイグレーション、インデックス、クエリプランerror-handling-expert— グローバルなエラー戦略