1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

🚀 2025年最新!NestJS + Prisma で作る爆速TodoアプリAPI開発【完全ガイド】

Posted at

📌 はじめに

こんにちは!今回は NestJSPrisma を使って、モダンなTodoアプリのバックエンドAPIを一から構築していきます。

「APIってどうやって作るの?」「NestJSって何?」という初心者の方でも、この記事を読み終わる頃には本格的なRESTful APIが作れるようになります!

この記事は初心者向けに書かれています。コードを完全に理解する必要はありません。まずは「動くものを作る」ことを目標にしましょう!

🎯 今回作るもの

機能一覧

  • ✅ Todo一覧取得API
  • ✅ Todo作成API
  • ✅ Todo更新API(完了状態の切り替えなど)
  • ✅ Todo削除API
  • ✅ PostgreSQLでのデータ永続化
  • ✅ 自動API仕様書生成(Swagger)

使用技術スタック

  • バックエンド: NestJS 11.x
  • ORM: Prisma 6.x
  • データベース: PostgreSQL 15
  • コンテナ: Docker Compose
  • 言語: TypeScript

📦 環境バージョン

{
  "node": "20.x以上",
  "nestjs": "11.0.1",
  "prisma": "6.8.2",
  "@prisma/client": "6.8.2",
  "postgresql": "15",
  "typescript": "5.7.3"
}

🛠️ 開発準備

必要ツールのインストール

# Node.js (LTS版推奨)
# Docker & Docker Compose
# VS Code (推奨エディタ)
# Postman または Thunder Client (API テスト用)

プロジェクト構成

nest-react-todo-app/
├── backend/              # NestJS アプリケーション
├── docker-compose.yml    # PostgreSQL設定
└── README.md

🐘 Phase 1: PostgreSQL環境構築

最初にデータベース環境を整えましょう。Docker Composeを使って簡単にPostgreSQLを起動できます。

docker-compose.yml作成

ルートディレクトリに以下のファイルを作成してください:

version: '3.8'

services:
  postgres:
    image: postgres:15
    container_name: todo-postgres
    environment:
      # データベースの基本設定
      POSTGRES_DB: todoapp          # データベース名
      POSTGRES_USER: todouser       # ユーザー名
      POSTGRES_PASSWORD: todopass   # パスワード
    ports:
      - "5434:5432"  # ホスト:コンテナのポート番号
    volumes:
      # データを永続化するためのボリューム
      - postgres_data:/var/lib/postgresql/data
    restart: unless-stopped

volumes:
  # PostgreSQLのデータを保存するボリューム
  postgres_data:

PostgreSQL起動

# データベース起動
docker-compose up -d postgres

# 起動確認
docker-compose ps

ポート番号を5434にしているのは、ローカルにPostgreSQLがインストールされている場合の競合を避けるためです。

🏗️ Phase 2: NestJSプロジェクト作成

NestJS CLI インストール & プロジェクト作成

# NestJS CLI をグローバルインストール
npm i -g @nestjs/cli

# プロジェクト作成
nest new backend
cd backend

必要な依存関係インストール

# Prisma関連
npm install prisma @prisma/client

# 開発用依存関係
npm install -D prisma

🗃️ Phase 3: Prisma設定

Prisma初期化

# Prisma初期化
npx prisma init

環境変数設定

.envファイルを以下の内容に更新してください:

# Database
DATABASE_URL="postgresql://todouser:todopass@localhost:5434/todoapp"

データベーススキーマ定義

prisma/schema.prismaを以下の内容に更新してください:

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

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

// Todoテーブルの定義
model Todo {
  id          Int      @id @default(autoincrement()) // 主キー、自動採番
  title       String                                  // タイトル(必須)
  description String?                                 // 説明(任意)
  completed   Boolean  @default(false)                // 完了状態(デフォルト:未完了)
  createdAt   DateTime @default(now())                // 作成日時
  updatedAt   DateTime @updatedAt                     // 更新日時

  @@map("todos") // テーブル名を「todos」に設定
}

マイグレーション実行

# マイグレーション実行(初回)
npx prisma migrate dev --name init

# Prismaクライアント生成
npx prisma generate

マイグレーションとは、データベースのスキーマ変更を管理する仕組みです。Prismaが自動でSQLを生成してくれます!

🔧 Phase 4: PrismaService作成

NestJSでPrismaを使うためのサービスを作成します。

Prismaサービス生成

# Prismaサービス生成
nest g service prisma --no-spec

PrismaService実装

src/prisma/prisma.service.tsを以下の内容に更新してください:

/**
 * prisma.service.ts
 * 
 * Prismaクライアントの管理を行うサービス
 * データベースへの接続と切断を自動で管理します
 */

import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  /**
   * モジュール初期化時にデータベースに接続
   */
  async onModuleInit() {
    await this.$connect();
    console.log('✅ データベースに接続しました');
  }

  /**
   * アプリケーション終了時にデータベース接続を切断
   */
  async onModuleDestroy() {
    await this.$disconnect();
    console.log('✅ データベース接続を切断しました');
  }
}

📝 Phase 5: Todo機能実装

Todoモジュール生成

# Todoモジュール・コントローラー・サービスを一括生成
nest g resource todo --no-spec

nest g resourceコマンドは、モジュール、コントローラー、サービス、DTOファイルを一度に生成してくれる便利なコマンドです!

型定義(DTO)作成

まず、API通信で使用する型を定義しましょう。

CreateTodoDto

src/todo/dto/create-todo.dto.ts

/**
 * create-todo.dto.ts
 * 
 * 新規Todo作成時のリクエストデータの型定義
 */

import { IsString, IsOptional, IsNotEmpty, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class CreateTodoDto {
  @ApiProperty({
    description: 'Todoのタイトル',
    example: '買い物に行く',
    maxLength: 100
  })
  @IsString({ message: 'タイトルは文字列である必要があります' })
  @IsNotEmpty({ message: 'タイトルは必須です' })
  @MaxLength(100, { message: 'タイトルは100文字以内で入力してください' })
  title: string;

  @ApiProperty({
    description: 'Todoの詳細説明',
    example: '牛乳とパンを買う',
    required: false
  })
  @IsOptional()
  @IsString({ message: '説明は文字列である必要があります' })
  @MaxLength(500, { message: '説明は500文字以内で入力してください' })
  description?: string;
}

UpdateTodoDto

src/todo/dto/update-todo.dto.ts

/**
 * update-todo.dto.ts
 * 
 * Todo更新時のリクエストデータの型定義
 * 全てのフィールドがオプション(部分更新を可能にするため)
 */

import { PartialType } from '@nestjs/mapped-types';
import { CreateTodoDto } from './create-todo.dto';
import { IsBoolean, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class UpdateTodoDto extends PartialType(CreateTodoDto) {
  @ApiProperty({
    description: 'Todoの完了状態',
    example: true,
    required: false
  })
  @IsOptional()
  @IsBoolean({ message: '完了状態はtrue/falseで指定してください' })
  completed?: boolean;
}

TodoService実装

src/todo/todo.service.tsを以下の内容に更新してください:

/**
 * todo.service.ts
 *
 * Todo機能のサービス
 * ビジネスロジックの実装とデータベース操作を担当します。
 * Prismaを使用してデータベースとのやり取りを行います。
 */

import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateTodoDto } from './dto/create-todo.dto';
import { UpdateTodoDto } from './dto/update-todo.dto';
import { Todo } from '@prisma/client';

@Injectable()
export class TodoService {
  // PrismaServiceを依存注入
  constructor(private prisma: PrismaService) {}

  /**
   * 新しいTodoを作成
   *
   * @param {CreateTodoDto} createTodoDto - 作成するTodoのデータ
   * @returns {Promise<Todo>} 作成されたTodo
   */
  async create(createTodoDto: CreateTodoDto): Promise<Todo> {
    try {
      const todo = await this.prisma.todo.create({
        data: {
          title: createTodoDto.title,
          description: createTodoDto.description,
        },
      });

      console.log(`✅ Todo作成完了: ID=${todo.id}, Title="${todo.title}"`);
      return todo;
    } catch (error) {
      console.error('❌ Todo作成エラー:', error);
      throw new Error('Todoの作成に失敗しました');
    }
  }

  /**
   * 全てのTodoを取得
   * 作成日時の降順でソート
   *
   * @returns {Promise<Todo[]>} Todoの配列
   */
  async findAll(): Promise<Todo[]> {
    try {
      const todos = await this.prisma.todo.findMany({
        orderBy: {
          createdAt: 'desc', // 新しい順で取得
        },
      });

      console.log(`✅ Todo一覧取得完了: ${todos.length}件`);
      return todos;
    } catch (error) {
      console.error('❌ Todo一覧取得エラー:', error);
      throw new Error('Todoの取得に失敗しました');
    }
  }

  /**
   * 特定のIDのTodoを取得
   *
   * @param {number} id - 取得するTodoのID
   * @returns {Promise<Todo>} 取得したTodo
   * @throws {NotFoundException} Todoが見つからない場合
   */
  async findOne(id: number): Promise<Todo> {
    try {
      const todo = await this.prisma.todo.findUnique({
        where: { id },
      });

      if (!todo) {
        throw new NotFoundException(`ID=${id}のTodoが見つかりません`);
      }

      console.log(`✅ Todo取得完了: ID=${todo.id}, Title="${todo.title}"`);
      return todo;
    } catch (error) {
      if (error instanceof NotFoundException) {
        throw error;
      }
      console.error('❌ Todo取得エラー:', error);
      throw new Error('Todoの取得に失敗しました');
    }
  }

  /**
   * Todoを更新
   *
   * @param {number} id - 更新するTodoのID
   * @param {UpdateTodoDto} updateTodoDto - 更新するデータ
   * @returns {Promise<Todo>} 更新されたTodo
   * @throws {NotFoundException} Todoが見つからない場合
   */
  async update(id: number, updateTodoDto: UpdateTodoDto): Promise<Todo> {
    try {
      // まず対象のTodoが存在するかチェック
      await this.findOne(id);

      const todo = await this.prisma.todo.update({
        where: { id },
        data: updateTodoDto,
      });

      console.log(`✅ Todo更新完了: ID=${todo.id}, Title="${todo.title}"`);
      return todo;
    } catch (error) {
      if (error instanceof NotFoundException) {
        throw error;
      }
      console.error('❌ Todo更新エラー:', error);
      throw new Error('Todoの更新に失敗しました');
    }
  }

  /**
   * Todoを削除
   *
   * @param {number} id - 削除するTodoのID
   * @returns {Promise<Todo>} 削除されたTodo
   * @throws {NotFoundException} Todoが見つからない場合
   */
  async remove(id: number): Promise<Todo> {
    try {
      // まず対象のTodoが存在するかチェック
      await this.findOne(id);

      const todo = await this.prisma.todo.delete({
        where: { id },
      });

      console.log(`✅ Todo削除完了: ID=${todo.id}, Title="${todo.title}"`);
      return todo;
    } catch (error) {
      if (error instanceof NotFoundException) {
        throw error;
      }
      console.error('❌ Todo削除エラー:', error);
      throw new Error('Todoの削除に失敗しました');
    }
  }
}

TodoController実装

src/todo/todo.controller.tsを以下の内容に更新してください:

/**
 * todo.controller.ts
 *
 * Todo機能のコントローラー
 * HTTPリクエストを受け取り、適切なサービスメソッドを呼び出します
 * RESTful APIの設計に従ったエンドポイントを提供します
 */

import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  ParseIntPipe,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { TodoService } from './todo.service';
import { CreateTodoDto } from './dto/create-todo.dto';
import { UpdateTodoDto } from './dto/update-todo.dto';
import {
  ApiTags,
  ApiOperation,
  ApiResponse,
  ApiParam,
} from '@nestjs/swagger';

@ApiTags('todos')
@Controller('todos')
export class TodoController {
  constructor(private readonly todoService: TodoService) {}

  /**
   * 新しいTodoを作成
   * POST /todos
   */
  @Post()
  @HttpCode(HttpStatus.CREATED)
  @ApiOperation({ summary: '新しいTodoを作成' })
  @ApiResponse({
    status: 201,
    description: 'Todoが正常に作成されました',
  })
  @ApiResponse({
    status: 400,
    description: 'リクエストデータが不正です',
  })
  async create(@Body() createTodoDto: CreateTodoDto) {
    return this.todoService.create(createTodoDto);
  }

  /**
   * 全てのTodoを取得
   * GET /todos
   */
  @Get()
  @ApiOperation({ summary: '全てのTodoを取得' })
  @ApiResponse({
    status: 200,
    description: 'Todo一覧を正常に取得しました',
  })
  async findAll() {
    return this.todoService.findAll();
  }

  /**
   * 特定のTodoを取得
   * GET /todos/:id
   */
  @Get(':id')
  @ApiOperation({ summary: '特定のTodoを取得' })
  @ApiParam({
    name: 'id',
    description: 'TodoのID',
    type: 'number',
  })
  @ApiResponse({
    status: 200,
    description: 'Todoを正常に取得しました',
  })
  @ApiResponse({
    status: 404,
    description: '指定されたTodoが見つかりません',
  })
  async findOne(@Param('id', ParseIntPipe) id: number) {
    return this.todoService.findOne(id);
  }

  /**
   * Todoを更新
   * PATCH /todos/:id
   */
  @Patch(':id')
  @ApiOperation({ summary: 'Todoを更新' })
  @ApiParam({
    name: 'id',
    description: 'TodoのID',
    type: 'number',
  })
  @ApiResponse({
    status: 200,
    description: 'Todoが正常に更新されました',
  })
  @ApiResponse({
    status: 404,
    description: '指定されたTodoが見つかりません',
  })
  async update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateTodoDto: UpdateTodoDto,
  ) {
    return this.todoService.update(id, updateTodoDto);
  }

  /**
   * Todoを削除
   * DELETE /todos/:id
   */
  @Delete(':id')
  @ApiOperation({ summary: 'Todoを削除' })
  @ApiParam({
    name: 'id',
    description: 'TodoのID',
    type: 'number',
  })
  @ApiResponse({
    status: 200,
    description: 'Todoが正常に削除されました',
  })
  @ApiResponse({
    status: 404,
    description: '指定されたTodoが見つかりません',
  })
  async remove(@Param('id', ParseIntPipe) id: number) {
    return this.todoService.remove(id);
  }
}

AppModule更新

src/app.module.tsを以下の内容に更新してください:

/**
 * app.module.ts
 *
 * アプリケーションのルートモジュール
 * アプリケーション全体の依存関係を管理します。
 * 各機能モジュールをインポートし、アプリケーション全体を構成します。
 */

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TodoModule } from './todo/todo.module';
import { PrismaService } from './prisma/prisma.service';

/**
 * AppModule
 *
 * アプリケーションのルートモジュール
 * 以下のコンポーネントを統合します:
 * - Controllers: リクエストのハンドリング
 * - Services: ビジネスロジックの実装
 * - Providers: 依存性の注入
 */
@Module({
  imports: [TodoModule],
  controllers: [AppController],
  providers: [AppService, PrismaService],
})
export class AppModule {}

TodoModule更新

src/todo/todo.module.tsを以下の内容に更新してください:

/**
 * todo.module.ts
 * 
 * Todo機能のモジュール
 * Todo関連のコントローラー、サービス、依存関係を管理します
 */

import { Module } from '@nestjs/common';
import { TodoService } from './todo.service';
import { TodoController } from './todo.controller';
import { PrismaService } from '../prisma/prisma.service';

@Module({
  controllers: [TodoController],
  providers: [TodoService, PrismaService],
})
export class TodoModule {}

🌐 Phase 6: CORS設定とSwagger設定

main.ts設定

src/main.tsを以下の内容に更新してください:

/**
 * main.ts
 * 
 * NestJSアプリケーションのエントリーポイント
 * サーバーの起動とグローバル設定を行います
 */

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

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

  // バリデーションパイプの設定
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true, // DTOで定義されていないプロパティを除去
      forbidNonWhitelisted: true, // 不正なプロパティがある場合エラー
      transform: true, // リクエストデータを自動的にDTOインスタンスに変換
    }),
  );

  // CORS を有効化
  app.enableCors({
    origin: ['http://localhost:5173', 'http://localhost:3000'], // Reactアプリのオリジンを許可
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], // 許可するHTTPメソッド
    allowedHeaders: ['Content-Type', 'Authorization'], // 許可するヘッダー
    credentials: true, // クッキーなどの認証情報を含むリクエストを許可
  });

  // Swagger設定
  const config = new DocumentBuilder()
    .setTitle('Todo API')
    .setDescription('NestJS + Prisma で作るTodoアプリのAPI仕様書')
    .setVersion('1.0')
    .addTag('todos', 'Todo関連のAPI')
    .build();

  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api', app, document);

  const port = process.env.PORT ?? 3001;
  await app.listen(port);

  console.log(`🚀 サーバーが起動しました: http://localhost:${port}`);
  console.log(`📚 API仕様書: http://localhost:${port}/api`);
}

bootstrap().catch((error) => {
  console.error('❌ アプリケーションの起動に失敗しました:', error);
  process.exit(1);
});

🧪 Phase 7: 動作確認

サーバー起動

# 開発モードでサーバー起動
npm run start:dev

コンソールに以下のようなメッセージが表示されれば成功です:

✅ データベースに接続しました
🚀 サーバーが起動しました: http://localhost:3001
📚 API仕様書: http://localhost:3001/api

Swagger UIでAPI確認

ブラウザで http://localhost:3001/api にアクセスしてください。

以下のようなSwagger UIが表示され、各APIエンドポイントを確認・テストできます:

  • POST /todos - Todo作成
  • GET /todos - Todo一覧取得
  • GET /todos/{id} - 特定Todo取得
  • PATCH /todos/{id} - Todo更新
  • DELETE /todos/{id} - Todo削除

APIテスト手順

1. Todo作成テスト

Swagger UIで POST /todos をクリック:

{
  "title": "買い物に行く",
  "description": "牛乳とパンを買う"
}

2. Todo一覧取得テスト

GET /todos で作成したTodoが表示されることを確認

3. Todo更新テスト

PATCH /todos/{id} で完了状態を更新:

{
  "completed": true
}

4. Todo削除テスト

DELETE /todos/{id} で作成したTodoを削除

Postman/Thunder Clientでのテスト

Todo作成

POST http://localhost:3001/todos
Content-Type: application/json

{
  "title": "プログラミング学習",
  "description": "NestJSとPrismaを学ぶ"
}

Todo一覧取得

GET http://localhost:3001/todos

🐛 トラブルシューティング

よくあるエラーと解決方法

1. データベース接続エラー

Error: Can't reach database server

解決方法:

# PostgreSQLコンテナが起動しているか確認
docker-compose ps

# 停止している場合は起動
docker-compose up -d postgres

# .envファイルのDATABASE_URLを確認

2. ポート競合エラー

Error: listen EADDRINUSE: address already in use :::3001

解決方法:

# 使用中のプロセスを確認
lsof -i :3001

# プロセスを終了
kill -9 <PID>

# または別のポートを使用
PORT=3002 npm run start:dev

3. Prismaマイグレーションエラー

Error: Migration failed

解決方法:

# マイグレーションファイルをリセット
npx prisma migrate reset

# 再度マイグレーション実行
npx prisma migrate dev --name init

# Prismaクライアント再生成
npx prisma generate

4. バリデーションエラー

400 Bad Request: validation failed

解決方法:

  • リクエストボディのフィールド名とデータ型を確認
  • 必須フィールドが入力されているかチェック
  • 文字数制限を超えていないか確認

5. モジュールが見つからないエラー

Module not found: Cannot resolve '@prisma/client'

解決方法:

# node_modulesを削除して再インストール
rm -rf node_modules package-lock.json
npm install

# Prismaクライアント再生成
npx prisma generate

📊 データベース確認方法

Prisma Studio使用

# Prisma Studioを起動(GUIでデータベース確認)
npx prisma studio

ブラウザで http://localhost:5555 にアクセスしてデータを視覚的に確認できます。

SQL直接実行

# PostgreSQLコンテナに接続
docker exec -it todo-postgres psql -U todouser -d todoapp

# Todoテーブル確認
\dt
SELECT * FROM todos;

🎉 完成!

お疲れ様でした!これで本格的なTodoアプリのバックエンドAPIが完成です。

実装した機能の振り返り

CRUD操作完備: Create, Read, Update, Delete の全機能
型安全性: TypeScriptとPrismaによる完全な型保護
バリデーション: class-validatorによる入力検証
API仕様書: Swaggerによる自動ドキュメント生成
エラーハンドリング: 適切なHTTPステータスコードとエラーメッセージ
データベース永続化: PostgreSQLによるデータ保存

次のステップ

  1. フロントエンド開発: React + TypeScriptでUIを作成
  2. 認証機能: JWT認証の実装
  3. テスト: Jest/Supertest による自動テスト
  4. デプロイ: Heroku/Vercel等への本番環境デプロイ

🔧 追加機能の実装例

Todo検索機能

検索機能を追加したい場合の実装例:

SearchTodoDto作成

src/todo/dto/search-todo.dto.ts

import { IsString, IsNotEmpty, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class SearchTodoDto {
  @ApiProperty({
    description: '検索キーワード',
    example: '買い物',
    minLength: 1
  })
  @IsString({ message: '検索キーワードは文字列である必要があります' })
  @IsNotEmpty({ message: '検索キーワードは必須です' })
  @MinLength(1, { message: '検索キーワードは1文字以上入力してください' })
  keyword: string;
}

TodoServiceに検索メソッド追加

/**
 * Todoを検索
 * タイトルまたは説明文に検索キーワードが含まれるTodoを取得
 */
async searchTodos(keyword: string): Promise<Todo[]> {
  try {
    const todos = await this.prisma.todo.findMany({
      where: {
        OR: [
          {
            title: {
              contains: keyword,
              mode: 'insensitive', // 大文字小文字を区別しない
            },
          },
          {
            description: {
              contains: keyword,
              mode: 'insensitive',
            },
          },
        ],
      },
      orderBy: {
        createdAt: 'desc',
      },
    });

    console.log(`✅ Todo検索完了: キーワード="${keyword}", ${todos.length}件`);
    return todos;
  } catch (error) {
    console.error('❌ Todo検索エラー:', error);
    throw new Error('Todoの検索に失敗しました');
  }
}

TodoControllerに検索エンドポイント追加

/**
 * Todoを検索
 * POST /todos/search
 */
@Post('search')
@ApiOperation({ summary: 'Todoを検索' })
@ApiResponse({
  status: 200,
  description: '検索結果を正常に取得しました',
})
async searchTodos(@Body() searchTodoDto: SearchTodoDto) {
  return this.todoService.searchTodos(searchTodoDto.keyword);
}

ページネーション機能

大量のTodoを効率的に取得するためのページネーション:

PaginationDto作成

src/todo/dto/pagination.dto.ts

import { IsOptional, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';

export class PaginationDto {
  @ApiProperty({
    description: 'ページ番号',
    example: 1,
    minimum: 1,
    required: false,
    default: 1
  })
  @IsOptional()
  @Type(() => Number)
  @IsInt({ message: 'ページ番号は整数である必要があります' })
  @Min(1, { message: 'ページ番号は1以上である必要があります' })
  page?: number = 1;

  @ApiProperty({
    description: '1ページあたりの件数',
    example: 10,
    minimum: 1,
    maximum: 100,
    required: false,
    default: 10
  })
  @IsOptional()
  @Type(() => Number)
  @IsInt({ message: '件数は整数である必要があります' })
  @Min(1, { message: '件数は1以上である必要があります' })
  @Max(100, { message: '件数は100以下である必要があります' })
  limit?: number = 10;
}

ページネーション対応メソッド

/**
 * ページネーション付きでTodoを取得
 */
async findAllWithPagination(page: number = 1, limit: number = 10) {
  const skip = (page - 1) * limit;

  try {
    const [todos, total] = await Promise.all([
      this.prisma.todo.findMany({
        skip,
        take: limit,
        orderBy: {
          createdAt: 'desc',
        },
      }),
      this.prisma.todo.count(),
    ]);

    const totalPages = Math.ceil(total / limit);
    const hasNext = page < totalPages;
    const hasPrev = page > 1;

    return {
      todos,
      pagination: {
        currentPage: page,
        totalPages,
        totalItems: total,
        itemsPerPage: limit,
        hasNext,
        hasPrev,
      },
    };
  } catch (error) {
    console.error('❌ ページネーション取得エラー:', error);
    throw new Error('Todoの取得に失敗しました');
  }
}

📈 パフォーマンス最適化

データベースインデックス

prisma/schema.prismaにインデックスを追加:

model Todo {
  id          Int      @id @default(autoincrement())
  title       String
  description String?
  completed   Boolean  @default(false)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  // インデックス追加
  @@index([completed])
  @@index([createdAt])
  @@index([title])
  @@map("todos")
}

接続プール最適化

prisma/schema.prismaにデータベース接続設定を追加:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
  // 接続プール設定
  directUrl = env("DIRECT_URL")
}

.envに設定追加:

DATABASE_URL="postgresql://todouser:todopass@localhost:5434/todoapp?connection_limit=5&pool_timeout=10"
DIRECT_URL="postgresql://todouser:todopass@localhost:5434/todoapp"

🛡️ セキュリティ強化

レート制限

npm install @nestjs/throttler

src/app.module.tsに追加:

import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';

@Module({
  imports: [
    ThrottlerModule.forRoot([
      {
        ttl: 60000, // 1分間
        limit: 10,  // 10リクエストまで
      },
    ]),
    TodoModule,
  ],
  providers: [
    {
      provide: APP_GUARD,
      useClass: ThrottlerGuard,
    },
    AppService,
    PrismaService,
  ],
})
export class AppModule {}

入力サニタイゼーション

npm install class-sanitizer

DTOにサニタイゼーション追加:

import { Trim, Escape } from 'class-sanitizer';

export class CreateTodoDto {
  @Trim()
  @Escape()
  @IsString()
  @IsNotEmpty()
  title: string;

  @Trim()
  @Escape()
  @IsOptional()
  @IsString()
  description?: string;
}

🧪 テスト実装

ユニットテスト例

src/todo/todo.service.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { TodoService } from './todo.service';
import { PrismaService } from '../prisma/prisma.service';

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

  const mockPrismaService = {
    todo: {
      create: jest.fn(),
      findMany: jest.fn(),
      findUnique: jest.fn(),
      update: jest.fn(),
      delete: jest.fn(),
    },
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        TodoService,
        {
          provide: PrismaService,
          useValue: mockPrismaService,
        },
      ],
    }).compile();

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

  describe('create', () => {
    it('新しいTodoを作成できること', async () => {
      const createTodoDto = {
        title: 'テストタスク',
        description: 'テスト用の説明',
      };

      const mockTodo = {
        id: 1,
        ...createTodoDto,
        completed: false,
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      mockPrismaService.todo.create.mockResolvedValue(mockTodo);

      const result = await service.create(createTodoDto);

      expect(result).toEqual(mockTodo);
      expect(mockPrismaService.todo.create).toHaveBeenCalledWith({
        data: createTodoDto,
      });
    });
  });

  describe('findAll', () => {
    it('全てのTodoを取得できること', async () => {
      const mockTodos = [
        {
          id: 1,
          title: 'タスク1',
          description: '説明1',
          completed: false,
          createdAt: new Date(),
          updatedAt: new Date(),
        },
      ];

      mockPrismaService.todo.findMany.mockResolvedValue(mockTodos);

      const result = await service.findAll();

      expect(result).toEqual(mockTodos);
      expect(mockPrismaService.todo.findMany).toHaveBeenCalledWith({
        orderBy: { createdAt: 'desc' },
      });
    });
  });
});

E2Eテスト例

test/todo.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('TodoController (e2e)', () => {
  let app: INestApplication;

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

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

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

  describe('/todos (POST)', () => {
    it('新しいTodoを作成', () => {
      return request(app.getHttpServer())
        .post('/todos')
        .send({
          title: 'E2Eテストタスク',
          description: 'E2Eテスト用の説明',
        })
        .expect(201)
        .expect((res) => {
          expect(res.body.title).toBe('E2Eテストタスク');
          expect(res.body.completed).toBe(false);
        });
    });
  });

  describe('/todos (GET)', () => {
    it('Todo一覧を取得', () => {
      return request(app.getHttpServer())
        .get('/todos')
        .expect(200)
        .expect((res) => {
          expect(Array.isArray(res.body)).toBe(true);
        });
    });
  });
});

🚀 デプロイ準備

Docker設定

Dockerfile

# Node.js 20 LTS を使用
FROM node:20-alpine

# 作業ディレクトリを設定
WORKDIR /app

# package.jsonとpackage-lock.jsonをコピー
COPY package*.json ./

# 依存関係をインストール
RUN npm ci --only=production

# ソースコードをコピー
COPY . .

# Prismaクライアントを生成
RUN npx prisma generate

# アプリケーションをビルド
RUN npm run build

# ポートを公開
EXPOSE 3001

# アプリケーションを起動
CMD ["npm", "run", "start:prod"]

本番用docker-compose.yml

docker-compose.prod.yml

version: '3.8'

services:
  app:
    build: .
    ports:
      - "3001:3001"
    environment:
      - DATABASE_URL=postgresql://todouser:todopass@postgres:5432/todoapp
      - NODE_ENV=production
    depends_on:
      - postgres

  postgres:
    image: postgres:15
    environment:
      POSTGRES_DB: todoapp
      POSTGRES_USER: todouser
      POSTGRES_PASSWORD: todopass
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

環境変数設定

.env.production

NODE_ENV=production
DATABASE_URL="postgresql://todouser:todopass@postgres:5432/todoapp"
PORT=3001

🎯 まとめ

この記事では、NestJS + Prisma + PostgreSQL を使って本格的なTodoアプリのバックエンドAPIを構築しました。

学んだ技術

  • NestJS: モダンなNode.jsフレームワーク
  • Prisma: 次世代ORM
  • PostgreSQL: 信頼性の高いリレーショナルデータベース
  • Docker: コンテナ化技術
  • Swagger: API仕様書自動生成
  • TypeScript: 型安全なJavaScript

実装した機能

完全なCRUD API
型安全な開発環境
入力バリデーション
エラーハンドリング
API仕様書の自動生成
データベース永続化

コードの品質

  • Clean Code: 読みやすく保守しやすいコード
  • SOLID原則: 適切な責務分離
  • 型安全性: TypeScriptによる完全な型保護
  • テスタビリティ: ユニット・E2Eテスト対応

このAPIを基盤として、React フロントエンド、認証機能、リアルタイム通信など、さらなる機能拡張が可能です。

参考リンク

次回の フロントエンド編 では、このAPIを使ってReact + TypeScriptでUIを構築していきます!

Happy Coding! 🎉

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?