📃

NestJSのディレクト構造について考えてみた...

に公開

あくまで個人的にNestJSを導入して開発してく中での悩みと
そこからテンプレートを独学で改善を試みてる記事なので温かい目で
見ていただけると嬉しいです...

知見もない中で書いてみてるので、アドバイスもらえると助かります...

前提条件

会社の実装方針としていくつか制約がある中での改善した内容になります

  • JavaScriptからTypeScriptに言語を寄せていきたい
  • TypeSciptでBEを開発する
  • HTTPメソッドは POST に統一する...
  • SQLはチューニングなどの観点から極力生SQLを使用する

当初の問題

最初はDDDライクな構造を採用したが、以下の問題が発生:

  • 新機能追加で10個以上のファイル変更が必要
  • 型定義の配置場所が不明確(Entity/Domain/DTO の使い分け)
  • 「ApplicationService」「DomainService」の役割分担が曖昧
  • メンバー間での理解度のばらつき

採用した設計原則

チーム全体での振り返りを経て、以下3つの方針を決定:

1. 機能境界の明確化

各機能で必要なファイルを単一ディレクトリに集約。
機能間の依存関係を最小化し、変更影響範囲を局所化する。

2. レイヤー責任の単純化

  • Controller: HTTP要求・レスポンス処理のみ
  • Service: ビジネスロジック実装
  • Repository: データアクセス専用

ディレクトリ構造

基本方針:1機能 = 1ディレクトリで完結

src/
├── core/              # アプリケーション設定
├── shared/            # 共通機能(例外処理、ログ等)
└── modules/           # 機能実装
    ├── products/      # 商品管理
    ├── orders/        # 注文管理
    └── users/         # ユーザー管理

各機能ディレクトリの内部構造:

modules/products/
├── entities/          # DB構造定義(snake_case)
├── dto/               # API入出力定義(camelCase)
├── types/             # 複雑クエリ結果型
├── queries/           # 外部SQLファイル
├── products.controller.ts
├── products.service.ts
├── products.repository.ts
└── products.module.ts

型定義の分類

型定義を以下3つのカテゴリに分離:

Entity(データベース設計記載)

データベースのカラム名や、データ型などデータベースの設計内容を記載する。

@Entity('products')
export class ProductEntity {
  @Column({ name: 'product_name' })
  product_name: string;  // snake_case
}

Types(複雑クエリ結果)

複雑なクエリの実行結果の型を定義する。

export interface ProductWithCategoryResult {
  product_id: string;
  product_name: string;
  category_name: string;  // JOIN結果
}

DTO(APIの入出力定義)

class-validator を使用して、DTOにAPI入出力を記載するとともに
バリデーション処理を実装する。

export class CreateProductDto {
  @IsNotEmpty()
  productName: string;  // camelCase + バリデーション
}

技術的な工夫

トランザクション管理の自動化

@Transactionalデコレータを実装し、複数DB操作の自動コミット・ロールバックを実現した。

@Injectable()
export class ProductService {
  @Transactional()
  async createProductWithCategory(data: any, queryRunner?: QueryRunner) {
    await this.productRepository.create(data, queryRunner);
    await this.categoryRepository.update(data.categoryId, queryRunner);
    // 全て成功時のみコミット
  }
}

複雑SQLの外部管理

10行以上のクエリは外部SQLファイルに分離し、セキュリティを考慮したパラメータ化クエリを使用:

-- src/sql/products/product-with-category.sql
SELECT 
  p.id as product_id,
  p.product_name,
  p.price,
  p.stock_quantity,
  c.id as category_id,
  c.name as category_name
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE p.id = $1
  AND p.deleted_at IS NULL;

Repository側でパラメータ化クエリを実行:

async getProductWithCategory(
  productId: string,
): Promise<ProductWithCategoryResult[]> {
  const sql = await this.databaseService.loadSqlFile(
    'products/product-with-category.sql',
  );
  return this.databaseService.query<ProductWithCategoryResult[]>(sql, [
    productId,  // $1パラメータにバインド
  ]);
}

セキュリティ対策

  • パラメータ化クエリ($1, $2...)でSQLインジェクション防止
  • TypeORMが自動的にパラメータをエスケープ処理
  • 直接的な文字列結合は使用しない

エラーハンドリング設計

構造化エラーコードによる統一的なエラー処理を実装した。

// エラーコード定義
E30_03_001: {
  code: 'E30_03_001',
  httpStatus: 404,
  category: 'BUSINESS_ERROR',
  message: 'Product not found: productId={productId}',
  userMessage: '商品が見つかりません',
  logLevel: 'warn'
}

// 使用例
throw new BusinessException('E30_03_001', { productId: id });

エラーコード命名規則:E[Category][Module][Sequence]

  • Category: 10=認証, 20=検証, 30=業務, 40=外部API, 50=システム
  • Module: 機能番号(01=User, 02=Product, 03=Category...)
  • Sequence: 連番

最終的なディレクトリ構造詳細

プロジェクト全体

src/
├── main.ts              # アプリケーション起動
├── app.module.ts        # ルートモジュール
├── core/                # アプリケーション設定
│   ├── config/          # 環境設定
│   ├── constants/       # 定数定義
│   └── types/           # 共通型定義
├── shared/              # 共通機能
│   ├── database/        # DB接続設定
│   ├── exceptions/      # エラー処理
│   ├── decorators/      # カスタムデコレータ
│   └── logger/          # ログ機能
├── modules/             # 機能別モジュール
   └── products/        

機能モジュール内部構造

modules/products/
├── entities/            # データベース設計
│   └── product.entity.ts
├── dto/                 # API入出力定義
│   ├── create-product.dto.ts
│   ├── update-product.dto.ts
│   └── product-response.dto.ts
├── types/               # 複雑クエリ結果型
│   └── product-query.types.ts
├── queries/             # 複雑SQL(モジュール内)
├── products.controller.ts
├── products.service.ts
├── products.repository.ts
└── products.module.ts

まとめ

ここに記載しきれてないですが、構造化ログの実装など様々な改善を施してみました。
クラウドで本番運用することが多いので、CloudWatch logs などに吐き出しても見やすくなるような
工夫などもしています。

全体のディレクトリ構造と設計思想を見直して、「できるだけ開発が利用しやすくなるように」
という観点でテンプレートをブラッシュアップしてみました。

NestJSの知見が個人的に全くないため、これから開発していく中でより開発しやすくなるように
ブラッシュアップしていく予定です。

Discussion