T-CREATOR

Node.js クリーンアーキテクチャ実践:アダプタ/ユースケース/エンティティの分離

Node.js クリーンアーキテクチャ実践:アダプタ/ユースケース/エンティティの分離

Node.js でのアプリケーション開発において、ビジネスロジックとインフラ層の境界が曖昧になり、コードが複雑化してしまった経験はありませんか。クリーンアーキテクチャは、このような課題を解決するための設計思想です。本記事では、Node.js における「アダプタ」「ユースケース」「エンティティ」の分離に焦点を当て、実践的な実装方法を解説していきます。

初めての方でも理解しやすいよう、各レイヤーの役割と実装例を丁寧にご紹介しますので、ぜひ最後までお付き合いください。

背景

クリーンアーキテクチャとは

クリーンアーキテクチャは、Robert C. Martin(通称 Uncle Bob)によって提唱された設計思想で、ビジネスロジックを外部の技術詳細から独立させることを目指します。この思想の核心は「依存関係の方向性」にあり、外側のレイヤーが内側のレイヤーに依存する一方向の依存関係を保つことです。

Node.js アプリケーションにおいても、この原則を適用することで、フレームワークやデータベースといった技術的詳細に依存しない、保守性の高いコードを実現できます。

レイヤー構造の基本

クリーンアーキテクチャは、同心円状に複数のレイヤーで構成されます。内側から順に、エンティティ層、ユースケース層、インターフェースアダプタ層、フレームワーク&ドライバ層となっており、依存関係は常に内側に向かいます。

以下の図は、各レイヤーの関係性と依存の方向を示したものです。

mermaidflowchart TB
  subgraph outer["外側レイヤー"]
    fw["フレームワーク<br/>&ドライバ"]
  end
  subgraph adapter["インターフェース<br/>アダプタ層"]
    ctrl["コントローラ"]
    pres["プレゼンタ"]
    repo["リポジトリ実装"]
  end
  subgraph usecase["ユースケース層"]
    uc["ユースケース"]
  end
  subgraph entity["エンティティ層"]
    ent["エンティティ"]
  end

  fw -->|依存| ctrl
  fw -->|依存| repo
  ctrl -->|依存| uc
  repo -->|依存| uc
  uc -->|依存| ent
  pres -->|依存| uc

図の要点

  • 依存の矢印は常に内側(中心)に向かっています
  • エンティティ層は他のどのレイヤーにも依存しません
  • 外側のレイヤーほど技術的な詳細を扱います

Node.js での実装の意義

Node.js は柔軟性が高い反面、設計指針がないとコードが無秩序になりがちです。クリーンアーキテクチャを適用することで、以下のメリットが得られます。

Express や Fastify といったフレームワークの変更が容易になるだけでなく、ビジネスロジックのテストが独立して行えるようになります。また、データベースを PostgreSQL から MongoDB に切り替える場合でも、ユースケース層やエンティティ層への影響を最小限に抑えられるのです。

課題

従来の設計における問題点

多くの Node.js プロジェクトでは、コントローラーに直接データベースアクセスのコードを記述したり、ビジネスロジックをルーティング層に混在させたりしています。このような設計では、以下の問題が発生しやすくなります。

技術的な詳細とビジネスロジックが密結合してしまうと、テストが困難になり、フレームワークやデータベースの変更時に広範囲の修正が必要となってしまいます。また、ビジネスルールの再利用が難しく、同じロジックを複数箇所に記述する羽羔が生じるのです。

依存関係の逆転が必要な理由

従来の階層型アーキテクチャでは、上位層が下位層に依存する構造が一般的でした。しかし、この設計では、インフラ層の変更がビジネスロジック層に波及してしまいます。

クリーンアーキテクチャでは、依存関係逆転の原則(DIP)を用いることで、この問題を解決します。具体的には、インターフェースを内側の層で定義し、外側の層でその実装を提供することで、依存の方向を逆転させるのです。

以下の図は、依存関係逆転の仕組みを示しています。

mermaidflowchart LR
  subgraph before["従来の設計"]
    direction TB
    bl1["ビジネスロジック"] -->|依存| db1["データベース"]
  end

  subgraph after["依存関係逆転後"]
    direction TB
    bl2["ビジネスロジック"] -->|使用| iface["インターフェース"]
    db2["データベース実装"] -.->|実装| iface
  end

  before -.->|改善| after

図で理解できる要点

  • 従来の設計では、ビジネスロジックがデータベースに直接依存していました
  • 依存関係逆転後は、インターフェースを介することで依存の向きが逆転します
  • データベース実装がインターフェースに依存するため、ビジネスロジックは具体的な実装を知る必要がありません

分離の難しさ

実際のプロジェクトでクリーンアーキテクチャを導入する際、どこまで分離すべきか、どのようにレイヤーを切り分けるべきかの判断が難しいという声をよく耳にします。特に、小規模なプロジェクトでは過度な抽象化がかえって複雑性を増してしまうこともあるでしょう。

本記事では、Node.js における実践的なアプローチとして、必要最小限の分離から始める方法をご紹介します。

解決策

3 層の明確な分離

クリーンアーキテクチャを Node.js で実践するには、エンティティ層、ユースケース層、アダプタ層の 3 つに明確に分離することが重要です。それぞれの層が持つ責務を理解し、適切に実装することで、保守性の高いコードベースを構築できます。

エンティティ層の役割

エンティティ層は、アプリケーションの最も内側に位置し、ビジネスルールそのものを表現します。この層は外部のどの層にも依存せず、純粋なビジネスロジックのみを含むべきです。

エンティティは、データとそのデータに対する操作をカプセル化したオブジェクトとして定義します。データベースのスキーマや HTTP リクエストの形式とは独立して設計することがポイントとなります。

ユースケース層の役割

ユースケース層は、アプリケーション固有のビジネスルールを含みます。エンティティを操作して、特定の業務フローを実現するのがこの層の責務です。

ユースケースは、外部からの入力を受け取り、エンティティを操作し、結果を返すという流れで実装します。データベースや外部 API といった技術的詳細には依存せず、インターフェースを通じてこれらを利用するのです。

アダプタ層の役割

アダプタ層は、外部の世界とユースケース層を繋ぐ役割を担います。具体的には、コントローラー、プレゼンター、リポジトリ実装などが含まれるでしょう。

この層では、HTTP リクエストをユースケースが理解できる形式に変換したり、ユースケースの結果を JSON や HTML に変換したりします。また、データベースアクセスの具体的な実装もこの層に配置します。

以下の図は、3 層の相互作用とデータフローを示しています。

mermaidsequenceDiagram
  participant Client as クライアント
  participant Adapter as アダプタ層<br/>(Controller)
  participant UseCase as ユースケース層
  participant Entity as エンティティ層
  participant Repo as アダプタ層<br/>(Repository)

  Client->>Adapter: HTTP リクエスト
  Adapter->>UseCase: DTO で呼び出し
  UseCase->>Entity: ビジネスルール実行
  Entity-->>UseCase: 結果返却
  UseCase->>Repo: データ永続化要求
  Repo-->>UseCase: 完了通知
  UseCase-->>Adapter: 結果 DTO
  Adapter-->>Client: HTTP レスポンス

図で理解できる要点

  • クライアントからのリクエストは、アダプタ層(コントローラー)が最初に受け取ります
  • ユースケース層がビジネスフローを制御し、エンティティ層でビジネスルールを実行します
  • データの永続化はリポジトリを通じて行われ、ユースケース層は具体的な実装を知りません

依存関係の管理

各層の依存関係を適切に管理するため、TypeScript のインターフェースと依存性注入(DI)を活用します。ユースケース層でインターフェースを定義し、アダプタ層でその実装を提供することで、依存関係逆転の原則を実現できるのです。

具体例

ディレクトリ構成

Node.js プロジェクトで 3 層を分離する際の、推奨されるディレクトリ構成をご紹介します。

plaintextsrc/
├── domain/              # エンティティ層
│   ├── entities/
│   │   └── User.ts
│   └── repositories/    # リポジトリインターフェース
│       └── IUserRepository.ts
├── application/         # ユースケース層
│   ├── usecases/
│   │   └── CreateUser.ts
│   └── dto/
│       ├── CreateUserInput.ts
│       └── CreateUserOutput.ts
└── infrastructure/      # アダプタ層
    ├── controllers/
    │   └── UserController.ts
    ├── repositories/
    │   └── UserRepository.ts
    └── database/
        └── prisma.ts

このディレクトリ構成では、各層が明確に分離されており、責務の境界が一目で分かりやすくなっています。

エンティティの実装

まず、最も内側のエンティティ層から実装していきましょう。ここでは、ユーザーエンティティを例に説明します。

エンティティクラスの定義

typescript// src/domain/entities/User.ts

/**
 * ユーザーエンティティ
 * ビジネスルールとドメイン知識をカプセル化
 */
export class User {
  private readonly id: string;
  private name: string;
  private email: string;
  private readonly createdAt: Date;

  constructor(
    id: string,
    name: string,
    email: string,
    createdAt: Date = new Date()
  ) {
    this.id = id;
    this.name = name;
    this.email = email;
    this.createdAt = createdAt;

    // ビジネスルールのバリデーション
    this.validate();
  }

  // バリデーションロジック
  private validate(): void {
    if (!this.name || this.name.trim().length === 0) {
      throw new Error('名前は必須です');
    }

    if (!this.isValidEmail(this.email)) {
      throw new Error('無効なメールアドレス形式です');
    }
  }

  // メールアドレス形式チェック
  private isValidEmail(email: string): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  }
}

エンティティクラスでは、ビジネスルールに基づいたバリデーションを実装しています。データの整合性は、エンティティ自身が保証するのです。

ゲッターメソッドの追加

typescript// src/domain/entities/User.ts (続き)

export class User {
  // ... 前述のコード ...

  /**
   * ID を取得
   */
  getId(): string {
    return this.id;
  }

  /**
   * 名前を取得
   */
  getName(): string {
    return this.name;
  }

  /**
   * メールアドレスを取得
   */
  getEmail(): string {
    return this.email;
  }

  /**
   * 作成日時を取得
   */
  getCreatedAt(): Date {
    return this.createdAt;
  }

  /**
   * 名前を変更
   */
  changeName(newName: string): void {
    this.name = newName;
    this.validate();
  }
}

ゲッターメソッドにより、エンティティの内部状態を安全に公開できます。また、changeName のようなメソッドでは、変更後に再度バリデーションを実行することで、常に正しい状態を維持します。

リポジトリインターフェースの定義

エンティティ層では、データの永続化方法を抽象化するため、リポジトリのインターフェースを定義します。

typescript// src/domain/repositories/IUserRepository.ts

import { User } from '../entities/User';

/**
 * ユーザーリポジトリのインターフェース
 * 永続化の詳細は隠蔽し、ドメイン操作のみを公開
 */
export interface IUserRepository {
  /**
   * ユーザーを保存
   */
  save(user: User): Promise<void>;

  /**
   * ID でユーザーを検索
   */
  findById(id: string): Promise<User | null>;

  /**
   * メールアドレスでユーザーを検索
   */
  findByEmail(email: string): Promise<User | null>;

  /**
   * ユーザーを削除
   */
  delete(id: string): Promise<void>;
}

このインターフェースは、エンティティ層に配置されますが、実装はアダプタ層で行います。これにより、依存関係逆転の原則を実現できるのです。

ユースケースの実装

次に、ユースケース層を実装しましょう。ここでは、ユーザー作成のユースケースを例にします。

入力 DTO の定義

typescript// src/application/dto/CreateUserInput.ts

/**
 * ユーザー作成の入力データ
 * 外部からの入力を受け取る型
 */
export interface CreateUserInput {
  name: string;
  email: string;
}

DTO(Data Transfer Object)は、レイヤー間でデータを受け渡すための型です。エンティティとは分離して定義することで、外部の形式変更の影響を最小限に抑えられます。

出力 DTO の定義

typescript// src/application/dto/CreateUserOutput.ts

/**
 * ユーザー作成の出力データ
 * ユースケース実行結果を表現
 */
export interface CreateUserOutput {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

出力 DTO により、ユースケースの結果を外部に返す形式を定義します。エンティティをそのまま返すのではなく、必要な情報のみを含む DTO に変換することがポイントです。

ユースケースクラスの実装

typescript// src/application/usecases/CreateUser.ts

import { User } from '../../domain/entities/User';
import { IUserRepository } from '../../domain/repositories/IUserRepository';
import { CreateUserInput } from '../dto/CreateUserInput';
import { CreateUserOutput } from '../dto/CreateUserOutput';
import { v4 as uuidv4 } from 'uuid';

/**
 * ユーザー作成ユースケース
 * アプリケーション固有のビジネスフローを実装
 */
export class CreateUser {
  constructor(
    private readonly userRepository: IUserRepository
  ) {}

  /**
   * ユースケースを実行
   */
  async execute(
    input: CreateUserInput
  ): Promise<CreateUserOutput> {
    // 1. メールアドレスの重複チェック
    const existingUser =
      await this.userRepository.findByEmail(input.email);
    if (existingUser) {
      throw new Error(
        'このメールアドレスは既に使用されています'
      );
    }

    // 2. エンティティの生成
    const userId = uuidv4();
    const user = new User(userId, input.name, input.email);

    // 3. リポジトリを通じて永続化
    await this.userRepository.save(user);

    // 4. 出力 DTO に変換して返却
    return {
      id: user.getId(),
      name: user.getName(),
      email: user.getEmail(),
      createdAt: user.getCreatedAt(),
    };
  }
}

ユースケースでは、ビジネスフロー全体を制御します。リポジトリはコンストラクタで注入されるため、ユースケース自体は具体的なデータベース実装を知りません。

アダプタ層の実装

最後に、外部とユースケースを繋ぐアダプタ層を実装します。

リポジトリ実装

typescript// src/infrastructure/repositories/UserRepository.ts

import { PrismaClient } from '@prisma/client';
import { User } from '../../domain/entities/User';
import { IUserRepository } from '../../domain/repositories/IUserRepository';

/**
 * Prisma を使用したユーザーリポジトリの実装
 * IUserRepository インターフェースを実装
 */
export class UserRepository implements IUserRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async save(user: User): Promise<void> {
    await this.prisma.user.create({
      data: {
        id: user.getId(),
        name: user.getName(),
        email: user.getEmail(),
        createdAt: user.getCreatedAt(),
      },
    });
  }

  async findById(id: string): Promise<User | null> {
    const userData = await this.prisma.user.findUnique({
      where: { id },
    });

    if (!userData) {
      return null;
    }

    return new User(
      userData.id,
      userData.name,
      userData.email,
      userData.createdAt
    );
  }
}

リポジトリ実装では、Prisma という具体的な ORM を使用していますが、この詳細はユースケース層からは隠蔽されています。

リポジトリの追加メソッド

typescript// src/infrastructure/repositories/UserRepository.ts (続き)

export class UserRepository implements IUserRepository {
  // ... 前述のコード ...

  async findByEmail(email: string): Promise<User | null> {
    const userData = await this.prisma.user.findUnique({
      where: { email },
    });

    if (!userData) {
      return null;
    }

    return new User(
      userData.id,
      userData.name,
      userData.email,
      userData.createdAt
    );
  }

  async delete(id: string): Promise<void> {
    await this.prisma.user.delete({
      where: { id },
    });
  }
}

各メソッドでは、データベースから取得したデータをエンティティに変換して返します。これにより、ユースケース層は常にエンティティとして扱えるのです。

コントローラーの実装

typescript// src/infrastructure/controllers/UserController.ts

import { Request, Response } from 'express';
import { CreateUser } from '../../application/usecases/CreateUser';
import { IUserRepository } from '../../domain/repositories/IUserRepository';

/**
 * ユーザーコントローラー
 * HTTP リクエストをユースケースに変換
 */
export class UserController {
  constructor(
    private readonly userRepository: IUserRepository
  ) {}

  /**
   * ユーザー作成エンドポイント
   */
  async create(req: Request, res: Response): Promise<void> {
    try {
      // 1. リクエストボディから入力 DTO を作成
      const input = {
        name: req.body.name,
        email: req.body.email,
      };

      // 2. ユースケースを生成して実行
      const createUser = new CreateUser(
        this.userRepository
      );
      const output = await createUser.execute(input);

      // 3. 結果を HTTP レスポンスに変換
      res.status(201).json({
        success: true,
        data: output,
      });
    } catch (error) {
      // 4. エラーハンドリング
      res.status(400).json({
        success: false,
        message:
          error instanceof Error
            ? error.message
            : '不明なエラー',
      });
    }
  }
}

コントローラーでは、HTTP リクエストをユースケースが理解できる形式に変換し、結果を HTTP レスポンスに変換します。ビジネスロジックは一切含まれません。

依存性注入の設定

各層を組み立てるため、依存性注入のセットアップを行います。

typescript// src/infrastructure/di/container.ts

import { PrismaClient } from '@prisma/client';
import { UserRepository } from '../repositories/UserRepository';
import { UserController } from '../controllers/UserController';

/**
 * 依存性注入コンテナ
 * アプリケーション全体の依存関係を管理
 */
export class DIContainer {
  private static instance: DIContainer;
  private prisma: PrismaClient;
  private userRepository: UserRepository;
  private userController: UserController;

  private constructor() {
    // Prisma クライアントの初期化
    this.prisma = new PrismaClient();

    // リポジトリの初期化
    this.userRepository = new UserRepository(this.prisma);

    // コントローラーの初期化
    this.userController = new UserController(
      this.userRepository
    );
  }

  static getInstance(): DIContainer {
    if (!DIContainer.instance) {
      DIContainer.instance = new DIContainer();
    }
    return DIContainer.instance;
  }

  getUserController(): UserController {
    return this.userController;
  }
}

DI コンテナにより、各層の依存関係を一箇所で管理できます。テスト時には、モックの実装を注入することも容易になるでしょう。

Express アプリケーションへの統合

最後に、Express アプリケーションにコントローラーを統合します。

typescript// src/infrastructure/server.ts

import express from 'express';
import { DIContainer } from './di/container';

/**
 * Express サーバーのセットアップ
 */
const app = express();
const container = DIContainer.getInstance();

// ミドルウェア設定
app.use(express.json());

// ルーティング設定
const userController = container.getUserController();
app.post('/users', (req, res) =>
  userController.create(req, res)
);

// サーバー起動
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

この実装により、Express というフレームワークの詳細は、アプリケーションの最も外側に配置されます。フレームワークを変更する場合でも、ユースケース層やエンティティ層への影響はありません。

まとめ

本記事では、Node.js におけるクリーンアーキテクチャの実践として、アダプタ層、ユースケース層、エンティティ層の 3 層分離について解説しました。

エンティティ層では、ビジネスルールそのものをカプセル化し、外部のどの層にも依存しない設計を実現します。ユースケース層では、アプリケーション固有のビジネスフローを制御し、インターフェースを通じて外部の技術詳細を利用するのです。そして、アダプタ層では、HTTP リクエストやデータベースアクセスといった技術的詳細を実装し、ユースケース層との橋渡しを行います。

この 3 層の明確な分離により、テストの容易性、保守性、拡張性が大幅に向上します。フレームワークやデータベースの変更が必要になった際にも、影響範囲を最小限に抑えられるでしょう。

初めは複雑に感じるかもしれませんが、一度理解すれば、長期的なメンテナンスコストを大幅に削減できます。ぜひ、実際のプロジェクトで試してみてください。

関連リンク