T-CREATOR

Prisma と NestJS を組み合わせたモダン API 設計

Prisma と NestJS を組み合わせたモダン API 設計

現代の Web 開発において、API 設計の重要性は年々高まっています。ユーザーの期待値が上がり続ける中、開発者は高速で安全、そして保守性の高い API を求められています。

Prisma と NestJS という 2 つの強力なツールを組み合わせることで、型安全性と開発効率を両立したモダンな API 設計が可能になります。この記事では、実際のプロジェクトで遭遇する課題とその解決策を交えながら、実践的な API 設計手法を学んでいきましょう。

モダン API 設計の重要性

API 設計は、アプリケーションの成功を左右する重要な要素です。良い API 設計は、開発効率の向上、バグの削減、そしてユーザー体験の向上につながります。

従来の API 開発における課題

従来の API 開発では、以下のような課題が頻繁に発生していました:

  • 型安全性の欠如: ランタイムエラーが多発
  • データベース操作の複雑さ: SQL クエリの管理が困難
  • コードの重複: 似たような処理の実装が散在
  • テストの困難さ: モック作成に時間がかかる
  • ドキュメントの不備: API 仕様の管理が不十分

これらの課題を解決するために、Prisma と NestJS の組み合わせが注目されています。

Prisma と NestJS の相性

Prisma と NestJS は、互いの弱点を補完し合う理想的な組み合わせです。

Prisma の強み

Prisma は、型安全なデータベースアクセスを提供する ORM です。TypeScript との相性が抜群で、コンパイル時にエラーを検出できます。

NestJS の強み

NestJS は、依存性注入やデコレータを活用した構造化されたフレームワークです。モジュール化された設計により、保守性の高いコードを書けます。

組み合わせによる相乗効果

この 2 つを組み合わせることで、以下のような相乗効果が得られます:

  • 型安全性の確保: エンドツーエンドでの型チェック
  • 開発効率の向上: 自動生成されるコードの活用
  • 保守性の向上: 明確な責任分離
  • テストの容易さ: モックしやすい構造

プロジェクト初期セットアップ

実際のプロジェクトで Prisma と NestJS を組み合わせる手順を見ていきましょう。

必要なパッケージのインストール

まず、必要なパッケージをインストールします:

bash# NestJS CLIのインストール
yarn global add @nestjs/cli

# 新しいプロジェクトの作成
nest new prisma-nestjs-api
cd prisma-nestjs-api

# Prismaのインストール
yarn add prisma @prisma/client
yarn add -D prisma

# 初期化
npx prisma init

よくあるエラーと解決策

初期セットアップ時に発生しがちなエラーを紹介します:

エラー 1: Prisma Client not found

arduinoError: Prisma Client is not generated. Please run `prisma generate` first.

このエラーは、Prisma Client が生成されていない場合に発生します。以下のコマンドで解決できます:

bashnpx prisma generate

エラー 2: Database connection failed

vbnetError: P1001: Can't reach database server at `localhost:5432`

データベース接続エラーです。.envファイルの設定を確認してください:

envDATABASE_URL="postgresql://username:password@localhost:5432/database_name"

Prisma スキーマ設計のベストプラクティス

Prisma スキーマの設計は、API の基盤となる重要な要素です。保守性と拡張性を考慮した設計を心がけましょう。

基本的なスキーマ設計

以下は、ユーザー管理システムの例です:

prisma// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("users")
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("posts")
}

リレーションシップの設計

リレーションシップを適切に設計することで、効率的なデータアクセスが可能になります:

prisma// 1対多の関係
model Category {
  id    Int    @id @default(autoincrement())
  name  String
  posts Post[]
}

model Post {
  id         Int      @id @default(autoincrement())
  title      String
  category   Category @relation(fields: [categoryId], references: [id])
  categoryId Int
}

よくあるスキーマエラー

エラー 1: Invalid relation field

javascriptError: Invalid relation field `posts` on model `User`.
The relation field `posts` in model `User` is missing the opposite relation field `author` in model `Post`.

リレーションシップの両方向を定義する必要があります:

prismamodel User {
  id    Int    @id @default(autoincrement())
  posts Post[] // これだけでは不十分
}

model Post {
  id     Int  @id @default(autoincrement())
  author User @relation(fields: [authorId], references: [id])
  authorId Int
}

NestJS モジュール構成の設計パターン

NestJS のモジュール構成は、アプリケーションの保守性を左右する重要な要素です。

基本的なモジュール構成

以下のような構成で、責任を明確に分離します:

typescript// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { PrismaService } from '../prisma/prisma.service';

@Module({
  controllers: [UsersController],
  providers: [UsersService, PrismaService],
  exports: [UsersService],
})
export class UsersModule {}

PrismaService の作成

Prisma Client を NestJS で使用するためのサービスを作成します:

typescript// src/prisma/prisma.service.ts
import {
  Injectable,
  OnModuleInit,
  OnModuleDestroy,
} from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService
  extends PrismaClient
  implements OnModuleInit, OnModuleDestroy
{
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

よくあるモジュールエラー

エラー 1: Circular dependency detected

rustError: Circular dependency detected: UsersModule -> PostsModule -> UsersModule

循環依存を避けるため、適切なモジュール設計が必要です:

typescript// 解決策:forwardRefを使用
@Module({
  imports: [forwardRef(() => PostsModule)],
  // ...
})
export class UsersModule {}

型安全なデータアクセス層の構築

型安全性を確保することで、ランタイムエラーを大幅に削減できます。

サービス層での型安全な実装

typescript// src/users/users.service.ts
import {
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto, UpdateUserDto } from './dto';

@Injectable()
export class UsersService {
  constructor(private prisma: PrismaService) {}

  async create(createUserDto: CreateUserDto) {
    return this.prisma.user.create({
      data: createUserDto,
    });
  }

  async findAll() {
    return this.prisma.user.findMany({
      include: {
        posts: true,
      },
    });
  }

  async findOne(id: number) {
    const user = await this.prisma.user.findUnique({
      where: { id },
      include: {
        posts: true,
      },
    });

    if (!user) {
      throw new NotFoundException(
        `User with ID ${id} not found`
      );
    }

    return user;
  }
}

DTO の定義

型安全性を確保するための DTO を定義します:

typescript// src/users/dto/create-user.dto.ts
import {
  IsEmail,
  IsString,
  IsOptional,
} from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @IsOptional()
  name?: string;
}

よくある型エラー

エラー 1: Type 'undefined' is not assignable to type 'string'

typescript// エラーの例
const user = await prisma.user.findUnique({
  where: { id: userId },
});

return user.name; // userがnullの場合にエラー

解決策:

typescript// 解決策
const user = await prisma.user.findUnique({
  where: { id: userId },
});

if (!user) {
  throw new NotFoundException('User not found');
}

return user.name;

バリデーションとエラーハンドリング

適切なバリデーションとエラーハンドリングは、API の信頼性を向上させます。

グローバルバリデーションパイプの設定

typescript// src/main.ts
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    })
  );

  await app.listen(3000);
}

カスタムエラーハンドラーの実装

typescript// src/common/filters/prisma-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpStatus,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Response } from 'express';

@Catch(Prisma.PrismaClientKnownRequestError)
export class PrismaExceptionFilter
  implements ExceptionFilter
{
  catch(
    exception: Prisma.PrismaClientKnownRequestError,
    host: ArgumentsHost
  ) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = 'Internal server error';

    switch (exception.code) {
      case 'P2002':
        status = HttpStatus.CONFLICT;
        message = 'Unique constraint violation';
        break;
      case 'P2025':
        status = HttpStatus.NOT_FOUND;
        message = 'Record not found';
        break;
    }

    response.status(status).json({
      statusCode: status,
      message,
      timestamp: new Date().toISOString(),
    });
  }
}

よくあるバリデーションエラー

エラー 1: Validation failed

json{
  "statusCode": 400,
  "message": ["email must be an email"],
  "error": "Bad Request"
}

適切なバリデーションルールを設定することで解決できます:

typescript// src/users/dto/create-user.dto.ts
import {
  IsEmail,
  IsString,
  MinLength,
} from 'class-validator';

export class CreateUserDto {
  @IsEmail(
    {},
    { message: 'Please provide a valid email address' }
  )
  email: string;

  @IsString()
  @MinLength(2, {
    message: 'Name must be at least 2 characters long',
  })
  name: string;
}

パフォーマンス最適化の実装

パフォーマンスは、ユーザー体験に直結する重要な要素です。

クエリの最適化

N+1 問題を避けるため、適切な include を使用します:

typescript// 非効率なクエリ(N+1問題)
const users = await this.prisma.user.findMany();
for (const user of users) {
  const posts = await this.prisma.post.findMany({
    where: { authorId: user.id },
  });
}

// 効率的なクエリ
const users = await this.prisma.user.findMany({
  include: {
    posts: true,
  },
});

ページネーションの実装

typescript// src/users/users.service.ts
async findAll(page: number = 1, limit: number = 10) {
  const skip = (page - 1) * limit;

  const [users, total] = await Promise.all([
    this.prisma.user.findMany({
      skip,
      take: limit,
      include: {
        posts: true,
      },
    }),
    this.prisma.user.count(),
  ]);

  return {
    data: users,
    meta: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  };
}

よくあるパフォーマンスエラー

エラー 1: Query timeout

bashError: Query timeout after 5000ms

解決策として、インデックスの追加やクエリの最適化が必要です:

sql-- インデックスの追加
CREATE INDEX idx_user_email ON users(email);
CREATE INDEX idx_post_author_id ON posts(author_id);

テスト戦略と実装

テストは、コードの品質を保証する重要な要素です。

ユニットテストの実装

typescript// src/users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { PrismaService } from '../prisma/prisma.service';
import { NotFoundException } from '@nestjs/common';

describe('UsersService', () => {
  let service: UsersService;
  let prisma: PrismaService;

  beforeEach(async () => {
    const module: TestingModule =
      await Test.createTestingModule({
        providers: [
          UsersService,
          {
            provide: PrismaService,
            useValue: {
              user: {
                findUnique: jest.fn(),
                create: jest.fn(),
              },
            },
          },
        ],
      }).compile();

    service = module.get<UsersService>(UsersService);
    prisma = module.get<PrismaService>(PrismaService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('findOne', () => {
    it('should return a user when found', async () => {
      const mockUser = { id: 1, email: 'test@example.com' };
      jest
        .spyOn(prisma.user, 'findUnique')
        .mockResolvedValue(mockUser);

      const result = await service.findOne(1);
      expect(result).toEqual(mockUser);
    });

    it('should throw NotFoundException when user not found', async () => {
      jest
        .spyOn(prisma.user, 'findUnique')
        .mockResolvedValue(null);

      await expect(service.findOne(999)).rejects.toThrow(
        NotFoundException
      );
    });
  });
});

E2E テストの実装

typescript// test/users.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

describe('Users (e2e)', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule =
      await Test.createTestingModule({
        imports: [AppModule],
      }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/users (GET)', () => {
    return request(app.getHttpServer())
      .get('/users')
      .expect(200);
  });

  afterAll(async () => {
    await app.close();
  });
});

よくあるテストエラー

エラー 1: Cannot find module '@nestjs/testing'

arduinoError: Cannot find module '@nestjs/testing'

解決策:

bashyarn add -D @nestjs/testing

エラー 2: Database connection failed in tests

テスト用のデータベース設定が必要です:

typescript// test/jest-e2e.json
{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  }
}

本番環境での運用考慮事項

本番環境では、セキュリティとパフォーマンスが最優先事項になります。

環境変数の管理

env# .env.production
DATABASE_URL="postgresql://username:password@production-host:5432/database_name"
NODE_ENV=production
PORT=3000

ログ設定の最適化

typescript// src/main.ts
import { Logger } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    logger: ['error', 'warn', 'log'],
  });

  const logger = new Logger('Bootstrap');
  logger.log('Application is starting...');

  await app.listen(process.env.PORT || 3000);
}

ヘルスチェックの実装

typescript// src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

@Controller('health')
export class HealthController {
  constructor(private prisma: PrismaService) {}

  @Get()
  async check() {
    try {
      await this.prisma.$queryRaw`SELECT 1`;
      return {
        status: 'ok',
        timestamp: new Date().toISOString(),
      };
    } catch (error) {
      return { status: 'error', message: error.message };
    }
  }
}

よくある本番環境エラー

エラー 1: Connection pool exhausted

javascriptError: Connection pool exhausted

解決策として、接続プールの設定を調整します:

typescript// src/prisma/prisma.service.ts
@Injectable()
export class PrismaService extends PrismaClient {
  constructor() {
    super({
      datasources: {
        db: {
          url: process.env.DATABASE_URL,
        },
      },
      // 接続プールの設定
      __internal: {
        engine: {
          connectionLimit: 10,
        },
      },
    });
  }
}

まとめ

Prisma と NestJS を組み合わせたモダン API 設計は、開発効率とコード品質を両立する強力なアプローチです。

この記事で学んだ重要なポイントを振り返ってみましょう:

型安全性の確保: Prisma の型生成機能と NestJS の TypeScript サポートにより、コンパイル時にエラーを検出できます。

開発効率の向上: 自動生成されるコードと明確な責任分離により、開発速度が大幅に向上します。

保守性の向上: モジュール化された設計と適切なエラーハンドリングにより、長期的な保守が容易になります。

パフォーマンスの最適化: 効率的なクエリ設計と適切なインデックスにより、高速な API レスポンスを実現できます。

実際のプロジェクトでは、これらの要素をバランスよく組み合わせることが重要です。最初は完璧を求めすぎず、段階的に改善していくことをお勧めします。

Prisma と NestJS の組み合わせは、現代の API 開発における最強のツールセットの一つです。この記事で学んだ知識を活用して、素晴らしい API を構築してください。

関連リンク