📌 はじめに
こんにちは!今回は NestJS と Prisma を使って、モダンな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によるデータ保存
次のステップ
- フロントエンド開発: React + TypeScriptでUIを作成
- 認証機能: JWT認証の実装
- テスト: Jest/Supertest による自動テスト
- デプロイ: 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! 🎉