こんにちは、エンジニアの清水です。
普段はフロントエンドを中心に開発していますが、最近はNestJSを使ったバックエンド開発にも携わるようになりました。
少し触ったことはあったものの、業務で本格的に使うのは初めてだったため、学んだことを整理してみました。
NestJSを学び始めた方や、フロントエンドからバックエンドに領域を広げたい方の参考になれば幸いです。
対象読者
- NestJSに興味のある方
- フロントエンドエンジニアでバックエンドにも挑戦したい方
1. NestJSとは
NestJSは、TypeScriptベースのNode.jsサーバーサイドフレームワークです。
内部的にはExpress(またはオプションで Fastify)を利用しており、使い慣れたNode.jsのエコシステムをそのまま活かせます。
Angularの設計思想に影響を受けており、デコレーターや DI(依存性注入)を中心としたアーキテクチャで構築されています。
フレームワークの規約に沿っていくだけで、テスト性・拡張性・保守性の高いアプリケーションを構築できるよう設計されているのが特徴です。
2. NestJSの基本構造
NestJSでは、アプリケーションを機能ごとにモジュールという単位で分割します。
この設計により、以下が実現できます。
- 各モジュールの責務が明確になる
- 機能ごとに独立してテストしやすくなる
- 必要な機能だけを再利用しやすくなる
また、各モジュールはController、Provider、Moduleという3つの要素で構成されます。
Controller
クライアントからのリクエストを受け取り、レスポンスを返す役割を担います。
@Controller() デコレータでルートパスを定義し、@Get() や @Post() などのデコレータで各エンドポイントを指定します。
// tasks/tasks.controller.ts @Controller('tasks') export class TasksController { @Get() findAll() { return 'タスク一覧'; } @Get(':id') findOne(@Param('id') id: string) { return `タスク ${id} の詳細`; } @Post() create(@Body() body: CreateTaskDto) { return 'タスク作成'; } }
Provider
ビジネスロジックを担当するクラスです。
@Injectable() デコレータを付けることで、NestJSのDIコンテナに登録されます。
これにより、Controllerや他のProviderに注入できるようになります。
代表的なProviderとしては、Serviceがあります。
// tasks/tasks.service.ts import { Injectable } from '@nestjs/common'; @Injectable() export class TasksService { private tasks = []; findAll() { return this.tasks; } create(task: string) { this.tasks.push(task); return task; } }
ControllerからServiceを利用する場合は、コンストラクタで注入します。
@Controller('tasks') export class TasksController { constructor(private readonly tasksService: TasksService) {} @Get() findAll() { return this.tasksService.findAll(); } @Post() create(@Body('task') task: string) { return this.tasksService.create(task); } }
Module
ControllerとProviderをまとめ、依存関係を管理する役割を担います。
@Module() デコレータで、そのモジュールに属する要素を定義します。
import { Module } from '@nestjs/common'; import { TasksController } from './tasks.controller'; import { TasksService } from './tasks.service'; @Module({ controllers: [TasksController], providers: [TasksService], }) export class TasksModule {}
作成したモジュールは、ルートモジュール(AppModule)でインポートします。
これにより、TasksModuleで定義したエンドポイント(/tasks)が有効になります:
// app.module.ts import { Module } from '@nestjs/common'; import { TasksModule } from './tasks/tasks.module'; @Module({ imports: [TasksModule], }) export class AppModule {}
3. ルーティングの仕組み
パラメータの取得
リクエストから値を取得するには、専用のデコレータを使います。
代表的なものは以下の通りです。
| デコレータ | 取得できる値 |
|---|---|
@Param() |
パスパラメータ(/tasks/:id) |
@Query() |
クエリパラメータ(?status=done) |
@Body() |
リクエストボディ |
@Headers() |
リクエストヘッダー |
@Controller('tasks') export class TasksController { @Get(':id') findOne( @Param('id') id: string, @Query('status') status?: string, ) { // GET /tasks/123?status=done // id = '123', status = 'done' } }
💡 ルート定義の順序に注意
NestJS(というより Express)のルーティングは上から順に評価されるため、動的パラメータ(:id)を含むルートは後ろに書くのが原則です。
// ❌ 問題のあるコード @Controller('tasks') export class TasksController { @Get(':id') findOne(@Param('id') id: string) {} @Get('today') findToday() {} }
この場合、/tasks/today にアクセスすると、先に定義されている :id が "today" としてマッチしてしまいます。
その結果、本来 findToday が処理すべきリクエストが /tasks/:id のルートに吸収されてしまい、findOne が呼ばれます。
解決方法はシンプルで、静的ルートを先に定義するだけです。
// ✅ 静的ルートを先に定義 @Controller('tasks') export class TasksController { @Get('today') findToday() {} @Get(':id') findOne(@Param('id') id: string) {} }
この挙動はExpress由来で、NestJSのIssueでも触れられています。
4. DTOでリクエストを型安全に
DTO(Data Transfer Object)は、ネットワーク経由でやり取りするデータの形を定義するオブジェクトです。
これにより、クライアントとサーバー間でどのようなデータを送受信するかが明確になります。
また、NestJSでは、DTOにはクラスを使用するのが特徴です。
TypeScriptのinterfaceはコンパイル後に消えてしまいますが、クラスは実行時にも存在するため、バリデーションなどに活用できます。
// dto/create-task.dto.ts export class CreateTaskDto { title: string; description?: string; }
// dto/update-task.dto.ts export class UpdateTaskDto { title?: string; description?: string; status?: 'todo' | 'doing' | 'done'; }
Controllerで型を指定することで、リクエストボディの構造が明確になります。
@Controller('tasks') export class TasksController { @Post() create(@Body() dto: CreateTaskDto) { return this.tasksService.create(dto); } @Put(':id') update(@Param('id') id: string, @Body() dto: UpdateTaskDto) { return this.tasksService.update(id, dto); } }
バリデーションの追加
class-validatorとValidationPipeを組み合わせると、デコレータでバリデーションルールを宣言できます。
これにより、不正なデータがControllerに届く前にDTOでブロックできます。
import { IsString, IsOptional, MaxLength } from 'class-validator'; export class CreateTaskDto { @IsString() @MaxLength(100) title: string; @IsOptional() @IsString() description?: string; }
バリデーションに失敗すると、以下のようなエラーレスポンスが自動で返されます。
{ "statusCode": 400, "error": "Bad Request", "message": ["title must be shorter than or equal to 100 characters"] }
5. DIとモジュール設計
DI(依存性注入)とは
NestJSはDI(Dependency Injection)というデザインパターンを中心に設計されています。
DIを使わない場合、クラス内で依存するオブジェクトを直接生成します。
// ❌ DIを使わない場合 class TasksController { private tasksService = new TasksService(); }
ただし、この書き方には問題があります。
- TasksControllerがTasksServiceの生成方法を知っている必要がある
- テスト時にTasksServiceをモックに差し替えられない
- TasksServiceの生成方法が変わると、利用側も修正が必要
DIを使うと、依存するオブジェクトは外部から注入されます。
// ✅ DIを使う場合 class TasksController { constructor(private tasksService: TasksService) {} }
これにより、NestJSでは以下のメリットが得られます。
- 疎結合 - ControllerやServiceが依存するクラスの生成方法を知らなくて済むため、クラス間の結合度が下がる
- テスタビリティ向上 - テスト時に依存するクラスをモックに差し替えられるので、単体テストがしやすくなる
- 再利用性 - DIコンテナを通じて異なる実装を注入できるため、同じクラスを別の用途でも再利用しやすくなる
NestJSでのDI
NestJSでは、TypeScriptの型情報を使って依存関係を解決します。
@Injectable() を付けたクラスは、DIコンテナに登録され、自動的にインスタンス化・注入されます。
// tasks/tasks.service.ts @Injectable() export class TasksService { // ... } // // tasks/tasks.controller.ts @Controller('tasks') export class TasksController { constructor(private tasksService: TasksService) {} }
これにより、TasksService はデフォルトでシングルトンとして管理されます。
つまり、アプリケーション全体で同じインスタンスが共有されます。
リクエストごとにインスタンスを生成したい場合は、スコープを変更できます。 詳しくはInjection Scopesを参照してください。
モジュール間の依存関係
モジュールはデフォルトでプロバイダをカプセル化します。
他のモジュールからプロバイダを使いたい場合は、exportsで公開する必要があります。
@Module({ controllers: [TasksController], providers: [TasksService], exports: [TasksService], // 他のモジュールに公開 }) export class TasksModule {}
// reports/reports.module.ts @Module({ imports: [TasksModule], // TasksModuleをインポート controllers: [ReportsController], providers: [ReportsService], }) export class ReportsModule {}
これでReportsServiceにTasksServiceを注入できます。
// reports/reports.service.ts @Injectable() export class ReportsService { constructor(private tasksService: TasksService) {} getDailySummary() { const tasks = this.tasksService.findAll(); return { total: tasks.length, completed: tasks.filter(t => t.status === 'done').length, }; } }
6. ORMについて
NestJSでは、様々なORMを利用できます。
本記事では、筆者のプロジェクトで採用しているPrismaを例に説明します。
他のORMについては公式ドキュメントを参照してください。
Prismaとは
PrismaはNode.js / TypeScript向けのオープンソースORMです。
以下の特徴があり、開発体験が良いのがポイントです。
- 直感的なスキーマ定義
- スキーマからTypeScriptの型を自動生成
- クエリの自動補完が効く
- 自動化されたマイグレーション
PrismaModuleの作成
NestJSでPrismaを使うには、PrismaServiceを作成してモジュール化します。
// prisma/prisma.service.ts import { Injectable, OnModuleInit } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit { async onModuleInit() { await this.$connect(); } }
// prisma/prisma.module.ts @Module({ providers: [PrismaService], exports: [PrismaService], }) export class PrismaModule {}
サービスでの利用
PrismaModuleをインポートすれば、各サービスでPrismaServiceを注入できます。
// tasks/tasks.module.ts @Module({ imports: [PrismaModule], // PrismaModuleをインポート controllers: [TasksController], providers: [TasksService], })
// tasks/tasks.service.ts @Injectable() export class TasksService { constructor(private prisma: PrismaService) {} findAll() { return this.prisma.task.findMany(); } findOne(id: string) { return this.prisma.task.findUnique({ where: { id } }); } create(data: CreateTaskDto) { return this.prisma.task.create({ data }); } }
まとめ
NestJSはDI を中心とした設計により、疎結合でテストしやすいアプリケーションを構築できます。
TypeScriptベースで書けるため、フロントエンドでの経験を活かしながらスムーズに取り組めた点も魅力でした。
言語を統一できる利点もありますが、それ以上にフレームワークの構造に従うだけで責務の分離やコードの統一感が自然と保たれることは、チーム開発において大きなメリットだと感じています。
また、型情報やレイヤー構造が明確なため、AIツールを使ったコード生成や補完にも向いていると考えています。
今後はNestJSの理解を深めつつ、AIを活用した効率的な開発フローも模索していきたいと思います。