Node.jsにおけるクリーンアーキテクチャ:プロジェクト拡大時の「スパゲッティコード」脱却術

Development tutorial - IT technology blog
Development tutorial - IT technology blog

「スパゲッティコード」という名の悪夢

コントローラーファイルを開いたとき、1000行もの混沌としたコードを目にしたことはありませんか?プロジェクトの開始当初は、コントローラーにコードを詰め込むことで機能を素早くリリースできます。しかし、プロジェクトが5万行を超えるあたりから、悪夢が始まります。データベースの些細なロジックを修正しただけで、メール送信機能や請求計算ロジックが壊れてしまうのです。

私はかつて、ビジネスロジックがExpressフレームワークと深く混ざり合っていたために、システムの整理(リファクタリング)だけで丸一週間を費やしたことがあります。そこでの痛い教訓は、「変更されやすいものは隔離すべき」ということです。データベースはMongoDBからPostgreSQLに変わるかもしれませんし、フレームワークはExpressからFastifyに移行するかもしれません。しかし、VIP顧客向けの割引計算ルールなどは、それらとは独立して保護されるべきなのです。

クリーンアーキテクチャ:単なる理論で終わらせない

クリーンアーキテクチャは複雑そうに見えるため、敬遠する人も多いでしょう。しかし、アンクル・ボブの黄金律は、実のところ一行に集約されます。それは、「依存関係は常に内側にのみ向かわなければならない」ということです。コアレイヤーは、データベースやUIといった外部の世界について何も知っていてはなりません。

実際のNode.jsプロジェクトでは、私は通常、以下の4つの保護レイヤーに分割します:

  • Domain (Entities): 最も基本的なビジネスルールを含む場所。技術的な変更に影響されない「不変」のレイヤーです。
  • Use Cases: 具体的な利用シナリオを含みます。例:メールアドレスの確認、パスワードの暗号化、保存といったアカウント登録のプロセスなど.
  • Interface Adapters: データの変換を行う架け橋。ここでコントローラーはユーザーからのリクエストを受け取り、ユースケースが理解できる形式に変換します。
  • Infrastructure: 外部ツールが含まれるレイヤー。Express、TypeORM、Redis、AWS SDKなどがここに配置されます。

3秒でファイルが見つかるディレクトリ構造

ファイルの種類(controllers, models)で分けるのではなく、機能とレイヤーで整理しましょう。この方法なら、Ctrl+Fを連打しなくても、目的のロジックをすぐに見つけ出すことができます:

src/
 ├── domain/           # エンティティとコアロジックを保持(例:User, Order)
 ├── use-cases/        # 具体的なビジネスロジック(例:RegisterUser, Checkout)
 ├── interfaces/       # コントローラー、DTO、およびリポジトリの定義
 ├── infrastructure/   # データベース、メールサービス、ロガーの実装
 └── main.ts           # 各パーツを結合するエントリーポイント

実践:ユーザー登録機能の構築

柔軟性を最大限に高めるために、APIをどのように細分化するか見てみましょう。

1. Domain Layer: システムの心臓部

このエンティティは完全に純粋です。MongooseやSequelizeのようなORMライブラリには一切依存しません。

// src/domain/entities/User.ts
export class User {
  constructor(
    public readonly id: string,
    public readonly email: string,
    public readonly password: string
  ) {}

  // 外部ライブラリを使用しないメールアドレスのバリデーションロジック
  public static isValidEmail(email: string): boolean {
    return /\S+@\S+\.\S+/.test(email);
  }
}

2. Use Case Layer: 業務の調整役

このレイヤーでは、リポジトリのインターフェースを定義します。ユースケースはインターフェースを呼び出すだけで、データがMySQLに保存されるのかExcelファイルに保存されるのかは関知しません。

// src/use-cases/register-user.use-case.ts
import { User } from "../domain/entities/User";
import { IUserRepository } from "./user-repository.interface";

export class RegisterUserUseCase {
  constructor(private userRepository: IUserRepository) {}

  async execute(data: any) {
    if (!User.isValidEmail(data.email)) throw new Error("メールアドレスの形式が正しくありません");

    const exists = await this.userRepository.findByEmail(data.email);
    if (exists) throw new Error("このメールアドレスは既に使用されています");

    const user = new User(Date.now().toString(), data.email, data.password);
    return await this.userRepository.save(user);
  }
}

3. Infrastructure Layer: 実際のデータベース接続

もし上司から「MongoDBからPostgreSQLに変えてくれ」と言われたら?ここに新しいリポジトリファイルを作成するだけです。上記のユースケースのロジックは、一行も修正する必要がありません。

// src/infrastructure/repositories/mongo-user.repository.ts
export class MongoUserRepository implements IUserRepository {
  async findByEmail(email: string) {
    // ここでMongooseモデルを呼び出す
    return null; 
  }

  async save(user: User) {
    console.log("MongoDBへの保存に成功しました");
    return user;
  }
}

実戦経験:いつ「ルールを破る」べきか?

クリーンアーキテクチャを機械的に適用しすぎると、オーバーエンジニアリング(過剰設計)を招く可能性があります。APIが3〜5個しかないMVP(Minimum Viable Product)プロジェクトの場合、レイヤーを細かく分けすぎると開発スピードが落ちてしまいます。時には従来のController-Service構成の方が効率的な場合もあります。

しかし、プロジェクトが長期運用されることが決まっているなら、ユニットテストを重視してください。このアーキテクチャの最大の強みは、テストが極めて容易なことです。データベースを起動せずに、リポジトリを完全にモック化してユースケースのロジックをテストできます。私はチームに対し、リファクタリング時の安全性を確保するために、ユースケースレイヤーのコードカバレッジを80%以上に保つよう推奨しています。

ちょっとしたコツ:Expressをインストールする前にユースケースを書いてみてください。Webサーバーなしでテストスクリプトからビジネスロジックを実行できれば、設計は成功です。

結論

クリーンアーキテクチャは魔法の杖ではなく、一種の「投資」です。最初は多くのファイルやインターフェースを作成することに時間がかかると感じるでしょう。しかし、一年後に技術スタックの変更や複雑な機能追加が必要になったとき、最初からレイヤーを明確に分けておいた自分に感謝することになるはずです。ぜひ、あなたのプロジェクトの次のモジュールで試してみてください!

Share: