Contents
1. 環境別 DataSource 設定と DI プロバイダー化
1-1 概要
NestJS アプリは開発・ステージング・本番の3つ以上の環境で動かすことが前提です。データベース接続情報を .env 系ファイル に分離し、@nestjs/config と TypeOrmModule.forRootAsync() を組み合わせることで、起動時に自動的に正しい設定がロードされます。本章では DataSource のプロバイダー化 までを網羅します。
1-2 環境ファイルの配置例
|
1 2 3 4 5 |
. ├─ .env.development ├─ .env.staging └─ .env.production |
| ファイル | 主な用途 |
|---|---|
.env.development |
ローカル開発用(DB はローカル PostgreSQL など) |
.env.staging |
ステージング環境(CI/CD のプレビューサーバー) |
.env.production |
本番環境(高可用性 DB クラスタ) |
1-3 ConfigModule の具体的設定
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import databaseConfig from './config/database.config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; @Module({ imports: [ // envFilePath に複数パスを渡すと、存在する最初のファイルがロードされる ConfigModule.forRoot({ isGlobal: true, load: [databaseConfig], envFilePath: ['.env.development', '.env.staging', '.env.production'], }), // 非同期で TypeORM のオプションを取得 TypeOrmModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (cs: ConfigService) => ({ ...cs.get('database'), // database.config から展開 entities: [__dirname + '/**/*.entity{.ts,.js}'], migrations: [__dirname + '/migrations/*{.ts,.js}'], }), }), // DataSource をアプリ全体で DI できるようにプロバイダー登録 TypeOrmModule.forFeature([]), // 必要ならエンティティを列挙 ], providers: [DataSourceProvider], exports: [DataSourceProvider], }) export class AppModule {} |
DataSource プロバイダーの実装
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// src/common/providers/data-source.provider.ts import { Provider } from '@nestjs/common'; import { DataSource } from 'typeorm'; export const DataSourceProvider: Provider = { provide: 'DATA_SOURCE', useFactory: async (dataSource: DataSource) => { // アプリ起動時に必ず初期化しておく if (!dataSource.isInitialized) await dataSource.initialize(); return dataSource; }, inject: [DataSource], }; |
- ポイント
provideに文字列トークン'DATA_SOURCE'を使用すれば、任意のサービスで@Inject('DATA_SOURCE')と注入可能。useFactory内でinitialize()を呼び出すことで、モジュールロード順に依存せずに接続が確立します。
1-4 まとめ(Key Takeaways)
.env.*を分割し、ConfigModule.forRoot({ envFilePath: [...] })で自動切替。TypeOrmModule.forRootAsync()と DI により環境変数を安全に注入。DataSourceProviderで DataSource をトークン化すれば、サービス層・リポジトリ層のどこからでも取得可能。
2. エンティティ設計のベストプラクティス
2-1 概要
エンティティはデータベースとビジネスロジックを結びつける重要な橋です。命名規則・カラム定義・インデックス設計を統一しないと、マイグレーションの差分が増えて保守コストが急上昇します。本章では 可読性・パフォーマンス・整合性 の三本柱に沿った実装例を示します。
2-2 命名規則とカラムオプション
| 要素 | 推奨スタイル |
|---|---|
| エンティティクラス | 単数形 PascalCase(User, OrderItem) |
| テーブル名 | スネークケース+複数形(users, order_items) |
| カラム名 | スネークケース (created_at)、型・長さ・null 許容を明示 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import { Entity, PrimaryGeneratedColumn, Column, Index, Unique } from 'typeorm'; @Entity({ name: 'users' }) @Unique(['email']) export class User { @PrimaryGeneratedColumn() id: number; @Column({ type: 'varchar', length: 100 }) @Index() // 検索頻度が高い列はインデックス付与 username: string; @Column({ type: 'varchar', length: 200, nullable: false }) email: string; @Column({ type: 'boolean', default: true }) is_active: boolean; @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) created_at: Date; } |
複合インデックス例
|
1 2 3 4 5 6 7 8 9 |
@Index(['first_name', 'last_name']) @Entity({ name: 'profiles' }) export class Profile { @PrimaryGeneratedColumn() id: number; @Column({ type: 'varchar', length: 50 }) first_name: string; @Column({ type: 'varchar', length: 50 }) last_name: string; } |
2-3 インデックス・ユニーク制約設計指針
- 検索頻度が高い列 →
@Index()(シングル)または@Index(['colA','colB'])(複合)。 - ビジネス上の重複禁止 →
@Unique()か@Column({ unique: true })。 - 実行計画の確認:
EXPLAIN ANALYZEをローカル DB で走らせ、インデックスが利用されているか必ず検証。
2-4 まとめ(Key Takeaways)
- エンティティは単数形・テーブルは複数形のスネークケースで統一。
- カラムは型・長さ・
nullableを明示し、不要なオプションは省くことでマイグレーション差分を最小化。 - インデックスは「検索頻度」と「クエリ構造」を基に設計し、実行計画で効果検証。
3. リポジトリ & サービス層の分離パターン
3-1 概要
永続化ロジックとビジネスロジックを混在させるとテストが困難になるだけでなく、将来別 ORM に置き換える際に大規模なリファクタリングが必要になります。本章では Domain‑Repository インターフェイス と Nest DI コンテナによる実装注入 の手順を示します。
3-2 ドメインインターフェイス定義
|
1 2 3 4 5 6 7 8 9 |
// src/user/domain/user.repository.ts import { User } from '../entities/user.entity'; export interface IUserRepository { findById(id: number): Promise<User | null>; findByEmail(email: string): Promise<User | null>; save(user: User): Promise<User>; } |
3-3 TypeORM 実装
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
// src/user/infrastructure/typeorm-user.repository.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { IUserRepository } from '../../domain/user.repository'; import { User } from '../entities/user.entity'; @Injectable() export class TypeOrmUserRepository implements IUserRepository { constructor( @InjectRepository(User) private readonly repo: Repository<User>, ) {} findById(id: number): Promise<User | null> { return this.repo.findOne({ where: { id } }); } findByEmail(email: string): Promise<User | null> { return this.repo.findOne({ where: { email } }); } async save(user: User): Promise<User> { return await this.repo.save(user); } } |
3-4 サービス層(ビジネスロジック)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// src/user/application/user.service.ts import { Injectable, Inject } from '@nestjs/common'; import { IUserRepository } from '../domain/user.repository'; import { CreateUserDto } from './dto/create-user.dto'; import { User } from '../../infrastructure/entities/user.entity'; @Injectable() export class UserService { constructor( @Inject('IUserRepository') private readonly userRepo: IUserRepository, ) {} async register(dto: CreateUserDto): Promise<User> { const exists = await this.userRepo.findByEmail(dto.email); if (exists) { throw new Error('メールアドレスは既に使用されています'); } const user = new User(); Object.assign(user, dto); return this.userRepo.save(user); } } |
3-5 モジュールでの DI 設定
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// src/user/user.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './entities/user.entity'; import { TypeOrmUserRepository } from './infrastructure/typeorm-user.repository'; import { UserService } from './application/user.service'; @Module({ imports: [TypeOrmModule.forFeature([User])], providers: [ { provide: 'IUserRepository', useClass: TypeOrmUserRepository, }, UserService, ], exports: [UserService], }) export class UserModule {} |
3-6 テストでのモック置換例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
// test/user.service.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { UserService } from '../src/user/application/user.service'; const mockRepo = { findByEmail: jest.fn(), save: jest.fn(), }; describe('UserService', () => { let service: UserService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ UserService, { provide: 'IUserRepository', useValue: mockRepo }, ], }).compile(); service = module.get<UserService>(UserService); }); it('should throw when email already exists', async () => { mockRepo.findByEmail.mockResolvedValue({ id: 1, email: 'test@example.com' }); await expect(service.register({ email: 'test@example.com', username: 'taro' })) .rejects .toThrow('メールアドレスは既に使用されています'); }); }); |
3-7 まとめ(Key Takeaways)
- インターフェイスで抽象化 → 実装切替が容易。
- DI コンテナにトークン登録 (
'IUserRepository') でサービス層から永続化ロジックを完全に分離。 - テストはリポジトリモックだけ差し替えれば、DB に依存しない高速ユニットテストが実現。
4. トランザクション管理とマイグレーション運用フロー
4-1 概要
複数テーブルに跨る書き込みは トランザクション で保護しなければデータ不整合が発生します。また、スキーマ変更は マイグレーションファイル として管理することで、チーム全体のデプロイを安全に行えます。本章では デコレータベーストランザクション と QueryRunner 手動制御 の使い分け、さらに CI に組み込むマイグレーション自動化手順を紹介します。
4-2 @Transaction デコレータのシンプルな利用
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
import { Injectable } from '@nestjs/common'; import { Transaction, TransactionManager, EntityManager, } from 'typeorm'; import { Order } from './entities/order.entity'; import { OrderItem } from './entities/order-item.entity'; @Injectable() export class OrderService { @Transaction() async placeOrder( userId: number, items: { productId: number; quantity: number }[], @TransactionManager() manager?: EntityManager, ) { const order = await manager.save(Order, { userId }); for (const i of items) { const detail = manager.create(OrderItem, { orderId: order.id, productId: i.productId, quantity: i.quantity, }); await manager.save(detail); } // 在庫更新やポイント付与など追加ロジックをここに書く return order; } } |
- メリット:メソッド単位で自動的に
BEGIN → COMMIT / ROLLBACKが走り、コードが非常にシンプル。 - 制限:複数リポジトリや外部 API 呼び出しを跨ぐ場合は手動制御の方が柔軟。
4-3 QueryRunner を用いた手動トランザクション
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; @Injectable() export class TransferService { constructor(private readonly ds: DataSource) {} async transfer(fromId: number, toId: number, amount: number) { const qr = this.ds.createQueryRunner(); await qr.connect(); await qr.startTransaction(); try { await qr.manager.decrement('account', { id: fromId }, 'balance', amount); await qr.manager.increment('account', { id: toId }, 'balance', amount); await qr.commitTransaction(); } catch (e) { await qr.rollbackTransaction(); throw e; } finally { await qr.release(); } } } |
- ポイント:開始・コミット・ロールバックをコードで明示的に制御できるため、外部サービス呼び出しや複数リポジトリ操作が混在するケースに最適。
4-4 マイグレーションの自動生成と CI 統合
- スキーマ変更 → マイグレーション自動生成
bash
npx typeorm migration:generate -n AddUserProfile - ローカルで実行・検証
bash
npm run migration:run # package.json に以下を追加しておくと便利 - package.json のスクリプト例
|
1 2 3 4 5 6 7 8 |
{ "scripts": { "migration:generate": "typeorm migration:generate -n", "migration:run": "typeorm migration:run", "migration:revert": "typeorm migration:revert" } } |
- GitHub Actions に組み込む例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
name: CI on: push: branches: [main, develop] jobs: build-and-migrate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Node uses: actions/setup-node@v3 with: node-version: '20' - run: npm ci - run: npm run build - name: Run DB migrations env: NODE_ENV: test DB_TYPE: sqlite DB_HOST: ':memory:' run: npm run migration:run |
- 失敗時の挙動:マイグレーションがエラーになるとジョブが即座に停止し、デプロイは保留されます。
4-5 まとめ(Key Takeaways)
| シナリオ | 推奨手法 |
|---|---|
| 単一メソッドで完結する DB 書き込み | @Transaction デコレータ |
| 複数リポジトリ・外部 API を跨ぐ複雑フロー | QueryRunner 手動制御 |
| スキーマ変更のチーム共有 | typeorm migration:generate → CI の migration:run |
5. パフォーマンス最適化と接続プール/ソフトデリート戦略
5-1 概要
本番環境では 接続プール が過不足するとスループットが変動し、N+1 問題 が顕在化するとレスポンスが急激に遅くなります。また、削除データを保持したいケースは ソフトデリート を導入するのが一般的です。本章では環境別プール設定例、eager vs lazy の選択指針、QueryBuilder での N+1 回避策、そして @DeleteDateColumn によるアーカイブ戦略をまとめます。
5-2 接続プールの環境別チューニング
|
1 2 3 4 5 6 7 8 9 10 11 |
// src/config/database.config.ts(追加部分) export default registerAs('database', () => ({ // ...既存項目は省略 extra: { max: Number(process.env.DB_POOL_MAX) || (process.env.NODE_ENV === 'production' ? 30 : process.env.NODE_ENV === 'staging' ? 15 : 5), idleTimeoutMillis: 30000, }, })); |
| 環境 | max 推奨値 |
コメント |
|---|---|---|
| development | 5 | ローカルリソース節約 |
| staging | 10~15 | 本番に近い負荷テスト用 |
| production | 20~30 | 高同時接続数に対応 |
5-3 eager vs lazy の選択指針
| 条件 | 推奨 |
|---|---|
| 常に取得したい親子関係(例: 投稿 → 作者) | @ManyToOne(..., { eager: true }) |
| 大量レコード・必要時のみ取得したい場合 | lazy (Promise<Relation[]>) + 明示的な QueryBuilder 結合 |
実装例
|
1 2 3 4 5 6 7 8 9 10 11 |
@Entity() export class Post { @PrimaryGeneratedColumn() id: number; // eager loading が適切なケース @ManyToOne(() => User, (u) => u.posts, { eager: true }) // lazy loading の宣言方法(Promise 型に注意) @OneToMany(() => Comment, (c) => c.post) comments: Promise<Comment[]>; } |
5-4 QueryBuilder で N+1 を防ぐパターン
|
1 2 3 4 5 6 7 8 9 |
const posts = await this.postRepo.createQueryBuilder('post') .leftJoinAndSelect('post.author', 'author') .leftJoinAndSelect('post.comments', 'comment') .where('author.id = :uid', { uid: userId }) .orderBy('post.created_at', 'DESC') .skip(0) .take(20) // ページングでメモリ使用量抑制 .getMany(); |
- ポイント:
leftJoinAndSelectにより関連エンティティを一括取得し、ループ内で個別 SELECT が走らないようにします。
5-5 ソフトデリート実装
|
1 2 3 4 5 6 7 8 9 10 11 |
@Entity({ name: 'articles' }) export class Article { @PrimaryGeneratedColumn() id: number; @Column() title: string; @Column() content: string; // TypeORM が自動的に NULL/日時管理するカラム @DeleteDateColumn() deleted_at?: Date; } |
- 削除:
repo.softRemove(entity)またはrepo.delete(id)(実際にはsoftDelete)でdeleted_atにタイムスタンプが入ります。 - 取得時のスコープ:
|
1 2 3 4 |
const active = await this.articleRepo.find({ where: { deleted_at: IsNull() }, // ソフトデリート除外条件 }); |
- アーカイブバッチ例(cron)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// src/tasks/archive.task.ts import { Cron, CronExpression } from '@nestjs/schedule'; import { Repository } from 'typeorm'; import { Article } from '../entities/article.entity'; @Injectable() export class ArchiveTask { constructor(@InjectRepository(Article) private readonly repo: Repository<Article>) {} @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) async purgeSoftDeleted() { await this.repo.createQueryBuilder() .delete() .where('deleted_at < :date', { date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }) .execute(); } } |
5-6 まとめ(Key Takeaways)
- 接続プールは環境に合わせて
maxを調整し、アイドルタイムアウトは一定に保つ。 - eager/lazy の選択は「取得頻度」と「レコードサイズ」で判断し、必要なら QueryBuilder で明示的に結合。
- N+1 回避は
leftJoinAndSelectとページングの併用が基本。 - ソフトデリートは
@DeleteDateColumn+ スコープクエリで実装し、定期バッチで物理削除を行う。
6. テスト環境での TypeORM 活用とエラーハンドリング/バリデーション統合
6-1 概要
本番コードと同一 ORM をテストに流用すれば、実装ミスマッチが減ります。軽量な SQLite インメモリ DB を利用しつつ、リポジトリインターフェイスのモック と class‑validator/ExceptionFilter の共通化で、信頼性の高いユニットテストと統合テストを実装できます。
6-2 SQLite インメモリ DB 設定
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// test/app.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from '../src/user/entities/user.entity'; @Module({ imports: [ TypeOrmModule.forRoot({ type: 'sqlite', database: ':memory:', entities: [User], synchronize: true, // テスト時はスキーマ自動同期可 logging: false, }), TypeOrmModule.forFeature([User]), ], }) export class TestAppModule {} |
synchronize: trueはテスト専用オプションで、マイグレーション不要の高速セットアップを実現します。
6-3 リポジトリモックのベストプラクティス
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// test/user.service.spec.ts(抜粋) const mockUserRepo = { findByEmail: jest.fn(), save: jest.fn(), }; beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ UserService, { provide: 'IUserRepository', useValue: mockUserRepo }, ], }).compile(); service = module.get(UserService); }); |
- ポイント:インターフェイス単位でモックを提供すれば、DB 接続が全く不要な純粋ユニットテストになる。
6-4 class-validator と class-transformer のグローバルパイプ
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
// src/common/pipes/validation.pipe.ts import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException, } from '@nestjs/common'; import { validate } from 'class-validator'; import { plainToInstance } from 'class-transformer'; @Injectable() export class ValidationPipe implements PipeTransform<any> { async transform(value: any, { metatype }: ArgumentMetadata) { if (!metatype || !this.isDto(metatype)) return value; const obj = plainToInstance(metatype, value); const errors = await validate(obj); if (errors.length) throw new BadRequestException(errors); return obj; } private isDto(metatype: any): boolean { const types = [String, Boolean, Number, Array, Object]; return !types.includes(metatype); } } |
|
1 2 3 4 5 6 7 8 9 |
// src/app.module.ts(抜粋) import { APP_PIPE } from '@nestjs/core'; import { ValidationPipe } from './common/pipes/validation.pipe'; @Module({ providers: [{ provide: APP_PIPE, useClass: ValidationPipe }], }) export class AppModule {} |
DTO の例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// src/user/dto/create-user.dto.ts import { IsEmail, IsNotEmpty, Length } from 'class-validator'; import { Transform } from 'class-transformer'; export class CreateUserDto { @IsNotEmpty() @Length(3, 30) username: string; @IsEmail() @Transform(({ value }) => value.toLowerCase()) email: string; } |
6-5 グローバル例外フィルター
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
// src/common/filters/all-exceptions.filter.ts import { ExceptionFilter, Catch, ArgumentsHost, HttpException, } from '@nestjs/common'; import { Request, Response } from 'express'; @Catch() export class AllExceptionsFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const request = ctx.getRequest<Request>(); const status = exception instanceof HttpException ? exception.getStatus() : 500; const message = exception instanceof HttpException ? (exception.getResponse() as any).message || exception.message : 'Internal server error'; response.status(status).json({ timestamp: new Date().toISOString(), path: request.url, error: message, }); } } |
|
1 2 3 4 5 6 7 8 9 10 11 12 |
// src/main.ts import { NestFactory } from '@nestjs/core'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalFilters(new AllExceptionsFilter()); await app.listen(3000); } bootstrap(); |
6-6 テストでのバリデーション・例外ハンドリング確認
|
1 2 3 4 5 6 7 8 9 |
it('should return BadRequest when DTO validation fails', async () => { const response = await request(app.getHttpServer()) .post('/users') .send({ username: 'a', email: 'invalid-email' }) .expect(400); expect(response.body.error).toContain('email must be an email'); }); |
6-7 まとめ(Key Takeaways)
| 項目 | 実装ポイント |
|---|---|
| DB | SQLite :memory: + synchronize:true でテスト環境を即時構築。 |
| リポジトリ | インターフェイス注入 → Jest のモックオブジェクトだけ差し替えれば DB 依存なし。 |
| バリデーション | グローバル ValidationPipe が全コントローラに適用され、テストでも同様に走る。 |
| 例外処理 | AllExceptionsFilter で未捕捉エラーも JSON 統一形式に変換し、フロントが期待する形を保証。 |
おわりに
本稿では NestJS + TypeORM の実務的な設定・設計からテストまで、一連の流れを階層化した見出し構造で整理しました。以下のチェックリストをプロジェクトに当てはめるだけで、安全かつスケーラブル なバックエンドが完成します。
- 環境別
.envとConfigModule.forRoot({ envFilePath })が正しく機能しているか。 - DataSource プロバイダー がトークン化され、どこからでも注入できるか。
- エンティティの命名・インデックスが統一されているか。
- リポジトリはインターフェイスで抽象化し、DI で実装を切り替えているか。
- トランザクションは @Transaction と QueryRunner を使い分け、マイグレーションが CI に組み込まれているか。
- 接続プール・eager‑lazy の選択と N+1 回避策が実装されているか。
- ソフトデリートと定期アーカイブバッチが整備されているか。
- テストは SQLite インメモリで走り、リポジトリモック・グローバルパイプ・例外フィルターが統一的に扱われているか。
これらを順守すれば、保守性・拡張性・信頼性 の高い NestJS アプリケーションが実現できます。ぜひ本ガイドをリファレンスとして、プロジェクトに即座に適用してください。 🚀