T-CREATOR

NestJS クリーンアーキテクチャ:UseCase/Domain/Adapter を疎結合に保つ設計術

NestJS クリーンアーキテクチャ:UseCase/Domain/Adapter を疎結合に保つ設計術

NestJS でアプリケーションを開発していると、ビジネスロジックとインフラストラクチャが密結合してしまい、テストが難しくなったり、仕様変更時の影響範囲が広がったりすることはないでしょうか。 クリーンアーキテクチャを導入すれば、UseCase・Domain・Adapter の各層を疎結合に保ち、保守性とテスタビリティの高いコードを実現できます。 本記事では、NestJS におけるクリーンアーキテクチャの実装方法を、具体的なコード例とともに詳しく解説していきますね。

背景

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

クリーンアーキテクチャは、ソフトウェアを「ビジネスルール(Domain)」「アプリケーションロジック(UseCase)」「外部インターフェース(Adapter)」という層に分離する設計思想です。 各層が明確な責務を持ち、依存関係を一方向に保つことで、変更に強く、テストしやすいシステムを構築できるのが特徴でしょう。

以下の図は、クリーンアーキテクチャの基本的な層構造を示しています。

mermaidflowchart TB
  subgraph outer["外部層(Adapter)"]
    controller["Controller<br/>REST/GraphQL"]
    repo["Repository実装<br/>DB/API"]
  end

  subgraph middle["アプリケーション層(UseCase)"]
    usecase["UseCase<br/>ビジネスフロー"]
  end

  subgraph inner["内部層(Domain)"]
    entity["Entity<br/>ビジネスルール"]
    port["Port(Interface)<br/>抽象化"]
  end

  controller -->|呼び出し| usecase
  usecase -->|利用| entity
  usecase -->|依存| port
  repo -.->|実装| port

  style inner fill:#e1f5ff
  style middle fill:#fff4e1
  style outer fill:#ffe1e1

上記の図で示したように、内側(Domain)は外側(Adapter)を知らず、外側が内側に依存するという依存性逆転の原則が守られています。 これにより、データベースや外部 API の変更がビジネスロジックに影響を与えないのです。

NestJS でクリーンアーキテクチャを採用する利点

NestJS は依存性注入(DI)の仕組みを標準で備えているため、クリーンアーキテクチャとの相性が抜群です。 プロバイダーやモジュールを活用することで、Interface(Port)と実装(Adapter)を簡単に切り替えられ、テスト時にはモックを注入するといった運用がスムーズに行えます。

#項目説明
1テスタビリティUseCase が Port(Interface)に依存するため、モックを簡単に注入可能
2保守性各層の責務が明確で、変更時の影響範囲を限定できる
3拡張性新しい Adapter(例:別の DB、外部 API)を追加しても UseCase を変更不要
4チーム開発層ごとに担当を分けやすく、並行開発が効率化される

課題

よくある密結合の問題

クリーンアーキテクチャを導入しない場合、以下のような問題が発生しがちです。

1. UseCase が具体的な実装に直接依存

UseCase の中で TypeORM のリポジトリや Prisma クライアントを直接利用してしまうと、データベースの変更時に UseCase 自体を書き換える必要が生じます。 これではビジネスロジックとインフラストラクチャが密結合し、テストも困難になってしまうでしょう。

2. ビジネスルールが Controller に散在

認証や権限チェック、バリデーションといったビジネスルールを Controller 層で記述してしまうケースも多く見られます。 このような設計では、同じルールを別のエンドポイントで再利用する際にコードの重複が発生し、保守性が低下してしまいますね。

3. テストのためにデータベースを起動する必要がある

UseCase が具体的な DB 実装に依存していると、ユニットテストを実行するたびに実際のデータベースを起動しなければなりません。 テストの実行速度が遅くなり、CI/CD パイプラインのボトルネックになる可能性があります。

以下の図は、密結合が発生している典型的な構成です。

mermaidflowchart LR
  ctrl["Controller"] -->|直接依存| typeorm["TypeORM<br/>Repository"]
  ctrl -->|直接依存| prisma["Prisma<br/>Client"]
  ctrl -->|ビジネスロジック| validation["バリデーション<br/>認証処理"]

  style ctrl fill:#ffcccc
  style typeorm fill:#ffcccc
  style prisma fill:#ffcccc

上記のように、Controller が複数の具体実装に直接依存してしまうと、変更の影響範囲が広がり、テストも複雑化します。

疎結合を実現するための要件

課題を解決し、疎結合なアーキテクチャを実現するには、以下の要件を満たす必要があります。

#要件目的
1Port(Interface)の定義UseCase が具体実装ではなく抽象に依存する
2Adapter の実装分離データベースや外部 API の実装を差し替え可能にする
3Dependency Injection の活用NestJS のプロバイダー機能で Port と Adapter を紐付ける
4Domain の独立性確保Entity やビジネスルールが外部技術に依存しない

解決策

層ごとの責務を明確化

クリーンアーキテクチャでは、各層が以下の責務を担います。

Domain 層(内部層)

  • Entity: ビジネスルールを持つドメインオブジェクト。外部技術に一切依存しません。
  • Port(Interface): データアクセスや外部サービスとの通信を抽象化したインターフェース。

UseCase 層(アプリケーション層)

  • UseCase: ビジネスフローを実装するクラス。Port を通じて外部と通信し、Entity を操作します。

Adapter 層(外部層)

  • Controller: HTTP リクエストを受け取り、UseCase を呼び出します。
  • Repository: データベースや外部 API との実際の通信を実装し、Port を実装します。

以下の図は、各層の依存関係を示したものです。

mermaidflowchart TB
  subgraph adapter["Adapter層"]
    controller["UserController"]
    repository["UserRepositoryImpl"]
  end

  subgraph usecase["UseCase層"]
    uc["CreateUserUseCase"]
  end

  subgraph domain["Domain層"]
    entity["User Entity"]
    port["IUserRepository<br/>(Port)"]
  end

  controller -->|呼び出し| uc
  uc -->|依存| port
  uc -->|利用| entity
  repository -.->|実装| port

  style domain fill:#d4edda
  style usecase fill:#fff3cd
  style adapter fill:#f8d7da

上記の構成により、UseCase は Port のみに依存し、具体的な Repository 実装を知る必要がありません。

Port(Interface)の定義

まず、Domain 層に Port を定義します。 Port は、UseCase が必要とするデータアクセスや外部サービスのメソッドを抽象化したインターフェースです。

typescript// src/domain/ports/user-repository.port.ts

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

export interface IUserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<User>;
  delete(id: string): Promise<void>;
}

上記のコードでは、IUserRepository という Port を定義しています。 このインターフェースを通じて、UseCase はユーザーデータの取得・保存・削除を行いますが、具体的な実装方法(TypeORM、Prisma、など)は知りません。

Entity の実装

次に、ビジネスルールを持つ Entity を定義しましょう。 Entity は外部技術に依存せず、純粋な TypeScript クラスとして実装します。

typescript// src/domain/entities/user.entity.ts

export class User {
  constructor(
    public readonly id: string,
    public name: string,
    public email: string,
    private _isActive: boolean = true
  ) {
    this.validateEmail(email);
  }

  // ビジネスルール:メールアドレスの形式チェック
  private validateEmail(email: string): void {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      throw new Error('Invalid email format');
    }
  }

  // ビジネスルール:アクティブ状態の変更
  deactivate(): void {
    this._isActive = false;
  }

  activate(): void {
    this._isActive = true;
  }

  get isActive(): boolean {
    return this._isActive;
  }
}

このコードでは、User Entity がメールアドレスのバリデーションやアクティブ状態の管理といったビジネスルールをカプセル化しています。 データベースの技術的な詳細(カラム定義、マッピングなど)は一切含まれていません。

UseCase の実装

UseCase は、Port を通じてデータアクセスを行い、Entity を操作してビジネスフローを実現します。

typescript// src/application/use-cases/create-user.use-case.ts

import { Injectable, Inject } from '@nestjs/common';
import { IUserRepository } from '../../domain/ports/user-repository.port';
import { User } from '../../domain/entities/user.entity';
import { v4 as uuidv4 } from 'uuid';

@Injectable()
export class CreateUserUseCase {
  constructor(
    @Inject('IUserRepository')
    private readonly userRepository: IUserRepository
  ) {}

  async execute(
    name: string,
    email: string
  ): Promise<User> {
    // 1. 新しいユーザーエンティティを作成
    const user = new User(uuidv4(), name, email);

    // 2. ビジネスルールの確認(例:重複チェック)
    const existingUser = await this.userRepository.findById(
      user.id
    );
    if (existingUser) {
      throw new Error('User already exists');
    }

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

上記の CreateUserUseCase は、IUserRepository という Port に依存しており、具体的な実装(TypeORM、Prisma など)を知りません。 このため、テスト時にはモックリポジトリを簡単に注入できます。

Adapter(Repository)の実装

Adapter 層では、Port を実装した具体的なクラスを作成します。 ここでは TypeORM を使った例を示しますが、Prisma や他の ORM に切り替える場合も、この層だけを変更すれば済みます。

typescript// src/infrastructure/repositories/user-repository.impl.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IUserRepository } from '../../domain/ports/user-repository.port';
import { User } from '../../domain/entities/user.entity';
import { UserEntity } from '../entities/user.entity.typeorm';

@Injectable()
export class UserRepositoryImpl implements IUserRepository {
  constructor(
    @InjectRepository(UserEntity)
    private readonly repository: Repository<UserEntity>
  ) {}

  async findById(id: string): Promise<User | null> {
    const userEntity = await this.repository.findOne({
      where: { id },
    });
    if (!userEntity) return null;

    // TypeORM エンティティから Domain エンティティへ変換
    return new User(
      userEntity.id,
      userEntity.name,
      userEntity.email,
      userEntity.isActive
    );
  }

  async save(user: User): Promise<User> {
    const userEntity = this.repository.create({
      id: user.id,
      name: user.name,
      email: user.email,
      isActive: user.isActive,
    });

    await this.repository.save(userEntity);
    return user;
  }

  async delete(id: string): Promise<void> {
    await this.repository.delete(id);
  }
}

このコードでは、TypeORM の Repository を使ってデータベース操作を実装しつつ、Domain の User Entity との変換を行っています。 UseCase は UserRepositoryImpl の存在を知らず、IUserRepository を通じてのみアクセスするため、疎結合が保たれますね。

TypeORM Entity の定義

TypeORM 用の Entity は、インフラストラクチャ層に配置します。 これは、データベースのテーブル構造を表現する技術的な詳細であり、Domain の Entity とは別物です。

typescript// src/infrastructure/entities/user.entity.typeorm.ts

import { Entity, Column, PrimaryColumn } from 'typeorm';

@Entity('users')
export class UserEntity {
  @PrimaryColumn()
  id: string;

  @Column()
  name: string;

  @Column()
  email: string;

  @Column({ default: true })
  isActive: boolean;
}

上記の UserEntity は TypeORM のデコレーターを使用しており、Domain 層の User とは完全に分離されています。

Module での依存性注入の設定

NestJS のモジュールシステムを使い、Port と Adapter を紐付けます。

typescript// src/user.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './infrastructure/entities/user.entity.typeorm';
import { UserRepositoryImpl } from './infrastructure/repositories/user-repository.impl';
import { CreateUserUseCase } from './application/use-cases/create-user.use-case';
import { UserController } from './presentation/controllers/user.controller';

@Module({
  imports: [TypeOrmModule.forFeature([UserEntity])],
  providers: [
    {
      provide: 'IUserRepository',
      useClass: UserRepositoryImpl,
    },
    CreateUserUseCase,
  ],
  controllers: [UserController],
})
export class UserModule {}

このモジュール設定により、IUserRepository という名前で UserRepositoryImpl が注入されます。 UseCase は @Inject('IUserRepository') でこの実装を受け取るため、具体的なクラス名を知る必要がありません。

Controller の実装

Controller は HTTP リクエストを受け取り、UseCase を呼び出すだけのシンプルな役割に徹します。

typescript// src/presentation/controllers/user.controller.ts

import { Controller, Post, Body } from '@nestjs/common';
import { CreateUserUseCase } from '../../application/use-cases/create-user.use-case';

@Controller('users')
export class UserController {
  constructor(
    private readonly createUserUseCase: CreateUserUseCase
  ) {}

  @Post()
  async createUser(
    @Body() body: { name: string; email: string }
  ) {
    const user = await this.createUserUseCase.execute(
      body.name,
      body.email
    );

    return {
      id: user.id,
      name: user.name,
      email: user.email,
      isActive: user.isActive,
    };
  }
}

上記のコードでは、Controller はビジネスロジックを一切持たず、UseCase に処理を委譲しています。 これにより、Controller のテストも簡潔になります。

具体例

ディレクトリ構成

クリーンアーキテクチャを採用した NestJS プロジェクトのディレクトリ構成例を示します。

csharpsrc/
├── domain/                    # Domain層(内部層)
│   ├── entities/
│   │   └── user.entity.ts     # ビジネスルールを持つEntity
│   └── ports/
│       └── user-repository.port.ts  # Port(Interface)
│
├── application/               # UseCase層(アプリケーション層)
│   └── use-cases/
│       ├── create-user.use-case.ts
│       ├── get-user.use-case.ts
│       └── delete-user.use-case.ts
│
├── infrastructure/            # Adapter層(外部層)
│   ├── entities/
│   │   └── user.entity.typeorm.ts   # TypeORM用Entity
│   └── repositories/
│       └── user-repository.impl.ts  # Port実装
│
├── presentation/              # Adapter層(外部層)
│   └── controllers/
│       └── user.controller.ts       # HTTP Controller
│
└── user.module.ts             # DIの設定

このディレクトリ構成では、各層が明確に分離され、依存関係が一方向に保たれています。

以下の図は、具体的なクラス間の依存関係を示しています。

mermaidflowchart TB
  subgraph presentation["Presentation(Controller)"]
    userCtrl["UserController"]
  end

  subgraph application["Application(UseCase)"]
    createUC["CreateUserUseCase"]
    getUC["GetUserUseCase"]
  end

  subgraph domain["Domain"]
    userEntity["User Entity"]
    iUserRepo["IUserRepository<br/>(Port)"]
  end

  subgraph infrastructure["Infrastructure(Repository)"]
    userRepoImpl["UserRepositoryImpl"]
    typeormEntity["UserEntity<br/>(TypeORM)"]
  end

  userCtrl -->|呼び出し| createUC
  userCtrl -->|呼び出し| getUC
  createUC -->|依存| iUserRepo
  createUC -->|利用| userEntity
  getUC -->|依存| iUserRepo
  userRepoImpl -.->|実装| iUserRepo
  userRepoImpl -->|使用| typeormEntity

  style domain fill:#d1ecf1
  style application fill:#fff3cd
  style infrastructure fill:#f8d7da
  style presentation fill:#e2e3e5

上記の図により、UseCase が Port にのみ依存し、具体的な Repository 実装を知らないことが視覚的に理解できますね。

テストコードの実装

疎結合なアーキテクチャの最大の利点は、テストが容易になることです。 以下では、モックリポジトリを使った UseCase のユニットテスト例を示します。

モックリポジトリの作成

typescript// src/application/use-cases/__tests__/mocks/user-repository.mock.ts

import { IUserRepository } from '../../../domain/ports/user-repository.port';
import { User } from '../../../domain/entities/user.entity';

export class UserRepositoryMock implements IUserRepository {
  private users: Map<string, User> = new Map();

  async findById(id: string): Promise<User | null> {
    return this.users.get(id) || null;
  }

  async save(user: User): Promise<User> {
    this.users.set(user.id, user);
    return user;
  }

  async delete(id: string): Promise<void> {
    this.users.delete(id);
  }
}

上記のモッククラスは、IUserRepository を実装しており、インメモリで動作するため、データベースを起動する必要がありません。

UseCase のユニットテスト

typescript// src/application/use-cases/__tests__/create-user.use-case.spec.ts

import { Test } from '@nestjs/testing';
import { CreateUserUseCase } from '../create-user.use-case';
import { UserRepositoryMock } from './mocks/user-repository.mock';

describe('CreateUserUseCase', () => {
  let useCase: CreateUserUseCase;
  let repository: UserRepositoryMock;

  beforeEach(async () => {
    repository = new UserRepositoryMock();

    const module = await Test.createTestingModule({
      providers: [
        CreateUserUseCase,
        {
          provide: 'IUserRepository',
          useValue: repository,
        },
      ],
    }).compile();

    useCase = module.get<CreateUserUseCase>(
      CreateUserUseCase
    );
  });

  it('should create a new user', async () => {
    // Arrange
    const name = 'John Doe';
    const email = 'john@example.com';

    // Act
    const user = await useCase.execute(name, email);

    // Assert
    expect(user.name).toBe(name);
    expect(user.email).toBe(email);
    expect(user.isActive).toBe(true);
  });

  it('should throw error for invalid email', async () => {
    // Arrange
    const name = 'Jane Doe';
    const invalidEmail = 'invalid-email';

    // Act & Assert
    await expect(
      useCase.execute(name, invalidEmail)
    ).rejects.toThrow('Invalid email format');
  });
});

このテストコードでは、モックリポジトリを注入することで、データベースに依存せずに UseCase の動作を検証しています。 テストの実行速度が速く、外部環境に影響されないため、CI/CD パイプラインでも安定して動作しますね。

複数の Adapter を切り替える例

疎結合な設計の利点を活かし、環境に応じて異なる Repository 実装を切り替える例を示します。

Prisma 実装の追加

typescript// src/infrastructure/repositories/user-repository-prisma.impl.ts

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma.service';
import { IUserRepository } from '../../domain/ports/user-repository.port';
import { User } from '../../domain/entities/user.entity';

@Injectable()
export class UserRepositoryPrismaImpl
  implements IUserRepository
{
  constructor(private readonly prisma: PrismaService) {}

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

    return new User(
      userRecord.id,
      userRecord.name,
      userRecord.email,
      userRecord.isActive
    );
  }

  async save(user: User): Promise<User> {
    await this.prisma.user.create({
      data: {
        id: user.id,
        name: user.name,
        email: user.email,
        isActive: user.isActive,
      },
    });
    return user;
  }

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

上記の Prisma 実装も、IUserRepository を実装しているため、UseCase を変更せずに利用できます。

環境変数による実装の切り替え

typescript// src/user.module.ts

import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './infrastructure/entities/user.entity.typeorm';
import { UserRepositoryImpl } from './infrastructure/repositories/user-repository.impl';
import { UserRepositoryPrismaImpl } from './infrastructure/repositories/user-repository-prisma.impl';
import { CreateUserUseCase } from './application/use-cases/create-user.use-case';
import { UserController } from './presentation/controllers/user.controller';
import { PrismaService } from './infrastructure/prisma.service';

@Module({
  imports: [TypeOrmModule.forFeature([UserEntity])],
  providers: [
    {
      provide: 'IUserRepository',
      useFactory: (
        configService: ConfigService,
        prisma: PrismaService
      ) => {
        const dbType = configService.get<string>('DB_TYPE');

        if (dbType === 'prisma') {
          return new UserRepositoryPrismaImpl(prisma);
        }

        // デフォルトはTypeORM
        return new UserRepositoryImpl(/* TypeORM Repository */);
      },
      inject: [ConfigService, PrismaService],
    },
    CreateUserUseCase,
    PrismaService,
  ],
  controllers: [UserController],
})
export class UserModule {}

このモジュール設定では、環境変数 DB_TYPE の値に応じて、TypeORM 実装または Prisma 実装を切り替えています。 UseCase や Controller は一切変更する必要がありません。

エラーハンドリングの実装

実際のアプリケーションでは、エラーハンドリングも重要です。 以下では、Domain 層でカスタムエラーを定義し、UseCase で適切に処理する例を示しましょう。

Domain エラーの定義

typescript// src/domain/errors/user.error.ts

export class UserNotFoundError extends Error {
  constructor(id: string) {
    super(`User with id ${id} not found`);
    this.name = 'UserNotFoundError';
  }
}

export class DuplicateUserError extends Error {
  constructor(email: string) {
    super(`User with email ${email} already exists`);
    this.name = 'DuplicateUserError';
  }
}

上記のカスタムエラーは、ビジネスルールの違反を表現するものです。

UseCase でのエラーハンドリング

typescript// src/application/use-cases/get-user.use-case.ts

import { Injectable, Inject } from '@nestjs/common';
import { IUserRepository } from '../../domain/ports/user-repository.port';
import { User } from '../../domain/entities/user.entity';
import { UserNotFoundError } from '../../domain/errors/user.error';

@Injectable()
export class GetUserUseCase {
  constructor(
    @Inject('IUserRepository')
    private readonly userRepository: IUserRepository
  ) {}

  async execute(id: string): Promise<User> {
    const user = await this.userRepository.findById(id);

    if (!user) {
      throw new UserNotFoundError(id);
    }

    return user;
  }
}

このコードでは、ユーザーが見つからない場合に UserNotFoundError をスローしています。

Controller でのエラーハンドリング

typescript// src/presentation/controllers/user.controller.ts

import {
  Controller,
  Get,
  Param,
  NotFoundException,
} from '@nestjs/common';
import { GetUserUseCase } from '../../application/use-cases/get-user.use-case';
import { UserNotFoundError } from '../../domain/errors/user.error';

@Controller('users')
export class UserController {
  constructor(
    private readonly getUserUseCase: GetUserUseCase
  ) {}

  @Get(':id')
  async getUser(@Param('id') id: string) {
    try {
      const user = await this.getUserUseCase.execute(id);

      return {
        id: user.id,
        name: user.name,
        email: user.email,
        isActive: user.isActive,
      };
    } catch (error) {
      if (error instanceof UserNotFoundError) {
        throw new NotFoundException(error.message);
      }
      throw error;
    }
  }
}

Controller では、Domain エラーを NestJS の HTTP 例外(NotFoundException)に変換しています。 これにより、適切な HTTP ステータスコード(404)がクライアントに返されます。

まとめ

NestJS でクリーンアーキテクチャを実践することで、UseCase・Domain・Adapter の各層を疎結合に保ち、保守性とテスタビリティの高いアプリケーションを構築できます。 本記事では、Port(Interface)と Adapter の分離、依存性注入の活用、そして環境に応じた実装の切り替え方法を具体的なコード例とともに解説しました。

図で理解できる要点

  • クリーンアーキテクチャは内側(Domain)が外側(Adapter)を知らず、依存性が一方向に保たれる
  • UseCase は Port に依存し、具体的な Repository 実装を知らないため、モック注入が容易
  • 環境変数や Factory パターンで複数の Adapter を切り替え可能

以下の表は、クリーンアーキテクチャ導入時の主な効果をまとめたものです。

#効果詳細
1テストの高速化モックリポジトリでデータベース不要のユニットテストが可能
2変更に強い設計データベースや外部 API の変更が UseCase に影響しない
3ビジネスロジックの再利用Domain・UseCase が技術詳細から独立しているため再利用が容易
4チーム開発の効率化層ごとに担当を分け、並行開発がスムーズになる

疎結合な設計を実現するには、最初は多少のコード量が増えるかもしれませんが、長期的な保守性とテスト容易性を考えれば、非常に価値のある投資と言えるでしょう。 ぜひ、あなたの NestJS プロジェクトにもクリーンアーキテクチャを取り入れて、より堅牢で拡張性の高いアプリケーションを構築してみてください。

関連リンク