T-CREATOR

TypeScript で実現するクリーンアーキテクチャ:層分離と依存性逆転の実践

TypeScript で実現するクリーンアーキテクチャ:層分離と依存性逆転の実践

現代のアプリケーション開発において、コードの品質と保守性は成功を左右する重要な要素です。特に TypeScript を使用した開発では、型安全性という強力な武器を手に入れた一方で、適切な設計パターンを適用しなければ、その恩恵を十分に活かしきれません。

本記事では、クリーンアーキテクチャを TypeScript で実践する方法を詳しく解説いたします。層分離と依存性逆転の原則を理解し、実際のコード例を通じて、保守性が高く、テストしやすいアプリケーションの構築方法をお伝えします。

背景

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

クリーンアーキテクチャは、ロバート・C・マーチン(Uncle Bob)によって提唱されたソフトウェア設計原則です。このアーキテクチャの核心は、ビジネスロジックを外部の技術的詳細から独立させることにあります。

クリーンアーキテクチャが重視する基本原則は以下のとおりです。

  • 依存性の方向性: 内側の層は外側の層を知らない
  • 関心の分離: 各層は明確な責務を持つ
  • テスタビリティ: ビジネスロジックが独立してテスト可能

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

mermaidgraph LR
    subgraph "外側の層"
        UI[プレゼンテーション層<br/>UI/Controllers]
        DB[インフラストラクチャ層<br/>Database/Web]
    end

    subgraph "内側の層"
        APP[アプリケーション層<br/>Use Cases]
        DOMAIN[ドメイン層<br/>Entities/Business Rules]
    end

    UI --> APP
    DB --> APP
    APP --> DOMAIN

    style DOMAIN fill:#e1f5fe
    style APP fill:#f3e5f5
    style UI fill:#fff3e0
    style DB fill:#f1f8e9

この図のとおり、依存関係は常に外側から内側に向かって流れ、内側の層は外側の詳細を知りません。

TypeScript での実装における課題

TypeScript でクリーンアーキテクチャを実装する際には、以下のような課題が存在します。

まず、型システムの活用方法が挙げられます。TypeScript の強力な型システムを活かして、各層間のインターフェースを明確に定義する必要があります。しかし、適切な抽象化を行わないと、具体的な実装に依存してしまい、クリーンアーキテクチャの恩恵を受けられません。

次に、依存性注入の実装です。Java や C#のような言語と比較して、TypeScript には標準的な DI コンテナが存在しないため、依存性の管理方法を慎重に設計する必要があります。

最後に、ファイル構成とモジュール分割の課題があります。各層を適切に分離し、循環参照を避けながら、開発者にとって理解しやすいディレクトリ構造を構築することが重要です。

課題

従来の MVC アーキテクチャの限界

従来の MVC アーキテクチャでは、以下のような問題が発生しがちです。

まず、ビジネスロジックの散在が挙げられます。モデル、ビュー、コントローラーそれぞれにビジネスロジックが分散してしまい、どこに何があるのかわからなくなってしまいます。

typescript// 問題のあるControllerの例
class UserController {
  async createUser(req: Request, res: Response) {
    // バリデーションロジック
    if (!req.body.email || !req.body.email.includes('@')) {
      return res
        .status(400)
        .json({ error: 'Invalid email' });
    }

    // ビジネスロジック
    const hashedPassword = await bcrypt.hash(
      req.body.password,
      10
    );

    // データベース操作
    const user = await User.create({
      email: req.body.email,
      password: hashedPassword,
      createdAt: new Date(),
    });

    // 外部サービス呼び出し
    await emailService.sendWelcomeEmail(user.email);

    res.json(user);
  }
}

このコードでは、コントローラーにバリデーション、ビジネスロジック、データベース操作、外部サービスの呼び出しがすべて含まれています。

層間の依存関係の問題

従来のアーキテクチャでは、以下のような依存関係の問題が発生します。

mermaidflowchart TD
    Controller[Controller] --> Service[Service]
    Service --> Model[Model]
    Service --> Database[Database]
    Service --> EmailAPI[Email API]
    Model --> Database

    style Controller fill:#ffcdd2
    style Service fill:#ffcdd2
    style Model fill:#ffcdd2
    style Database fill:#ffcdd2
    style EmailAPI fill:#ffcdd2

この構造では、上位層が下位層の具体的な実装に直接依存してしまい、変更の影響が連鎖的に波及してしまいます。

具体的な問題点:

問題影響
密結合変更困難データベースを変更すると全体に影響
テスト困難品質低下外部サービスをモックできない
再利用困難開発効率低下ビジネスロジックを他で使えない

テスタビリティの課題

従来のアーキテクチャでは、単体テストの作成が困難になります。

typescript// テストしにくいサービスの例
class UserService {
  constructor(
    private database: MySQL, // 具体的なデータベース
    private emailApi: SendGridAPI // 具体的なメールサービス
  ) {}

  async registerUser(userData: UserData) {
    // 直接データベースに依存
    const existingUser = await this.database.query(
      'SELECT * FROM users WHERE email = ?',
      [userData.email]
    );

    if (existingUser.length > 0) {
      throw new Error('User already exists');
    }

    const user = await this.database.insert(
      'users',
      userData
    );

    // 直接外部APIに依存
    await this.emailApi.send({
      to: userData.email,
      subject: 'Welcome!',
      body: 'Thank you for registering',
    });

    return user;
  }
}

このコードをテストするには、実際のデータベースとメールサービスが必要になってしまいます。

解決策

クリーンアーキテクチャの 4 層構造

クリーンアーキテクチャでは、アプリケーションを 4 つの層に分離します。

mermaidgraph TB
    subgraph "プレゼンテーション層"
        Controllers[Controllers<br/>REST API, GraphQL]
        UI[UI Components<br/>React, Vue]
    end

    subgraph "アプリケーション層"
        UseCases[Use Cases<br/>Application Services]
        Ports[Ports<br/>Interfaces]
    end

    subgraph "ドメイン層"
        Entities[Entities<br/>Business Objects]
        ValueObjects[Value Objects<br/>Immutable Values]
        DomainServices[Domain Services<br/>Business Logic]
    end

    subgraph "インフラストラクチャ層"
        Repositories[Repositories<br/>Data Access]
        ExternalServices[External Services<br/>APIs, Email]
    end

    Controllers --> UseCases
    UI --> UseCases
    UseCases --> Entities
    UseCases --> Ports
    Repositories --> Ports
    ExternalServices --> Ports

    style Entities fill:#e8f5e8
    style UseCases fill:#fff2e8
    style Controllers fill:#e8e8f5
    style Repositories fill:#f5e8e8

各層の責務は明確に分離されており、依存関係は内向きに流れています。

各層の役割:

責務技術例
ドメイン層ビジネスルールとロジックエンティティ、値オブジェクト
アプリケーション層ユースケースの調整サービス、ポート
インフラストラクチャ層外部リソースとの連携データベース、API
プレゼンテーション層ユーザーインターフェースコントローラー、UI

依存性逆転の原則(DIP)の適用

依存性逆転の原則により、高レベルのモジュールは低レベルのモジュールに依存せず、両方とも抽象に依存します。

mermaidflowchart TD
    subgraph "Before DIP"
        A1[High Level Module] --> B1[Low Level Module]
    end

    subgraph "After DIP"
        A2[High Level Module] --> I[Interface/Abstraction]
        B2[Low Level Module] --> I
    end

    style A1 fill:#ffcdd2
    style B1 fill:#ffcdd2
    style A2 fill:#c8e6c9
    style B2 fill:#c8e6c9
    style I fill:#fff9c4

TypeScript での実装例:

typescript// 抽象化(ポート)
interface UserRepository {
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<User>;
}

interface EmailService {
  sendWelcomeEmail(email: string): Promise<void>;
}
typescript// 高レベルモジュール(ユースケース)
class RegisterUserUseCase {
  constructor(
    private userRepository: UserRepository, // 抽象に依存
    private emailService: EmailService // 抽象に依存
  ) {}

  async execute(userData: UserData): Promise<User> {
    const existingUser =
      await this.userRepository.findByEmail(userData.email);

    if (existingUser) {
      throw new Error('User already exists');
    }

    const user = new User(userData);
    const savedUser = await this.userRepository.save(user);
    await this.emailService.sendWelcomeEmail(user.email);

    return savedUser;
  }
}

TypeScript での実装パターン

TypeScript でクリーンアーキテクチャを実装する際の主要なパターンをご紹介します。

1. インターフェースによる抽象化

typescript// ドメイン層でのインターフェース定義
export interface UserRepository {
  findById(id: UserId): Promise<User | null>;
  findByEmail(email: Email): Promise<User | null>;
  save(user: User): Promise<User>;
  delete(id: UserId): Promise<void>;
}

2. 依存性注入のパターン

typescript// DIコンテナの例
export class Container {
  private services = new Map<string, any>();

  register<T>(key: string, factory: () => T): void {
    this.services.set(key, factory);
  }

  resolve<T>(key: string): T {
    const factory = this.services.get(key);
    if (!factory) {
      throw new Error(`Service ${key} not found`);
    }
    return factory();
  }
}

3. ファクトリーパターンの活用

typescript// ユースケースファクトリー
export class UseCaseFactory {
  constructor(
    private userRepository: UserRepository,
    private emailService: EmailService
  ) {}

  createRegisterUserUseCase(): RegisterUserUseCase {
    return new RegisterUserUseCase(
      this.userRepository,
      this.emailService
    );
  }
}

具体例

実際のユーザー管理システムを例に、各層の実装方法を詳しく見ていきましょう。

ドメイン層の実装

ドメイン層は、ビジネスルールとエンティティを含む、アプリケーションの核となる部分です。

エンティティの実装

typescript// ユーザーID値オブジェクト
export class UserId {
  constructor(private readonly value: string) {
    if (!value || value.trim().length === 0) {
      throw new Error('UserId cannot be empty');
    }
  }

  equals(other: UserId): boolean {
    return this.value === other.value;
  }

  toString(): string {
    return this.value;
  }
}
typescript// メールアドレス値オブジェクト
export class Email {
  private static readonly EMAIL_REGEX =
    /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  constructor(private readonly value: string) {
    if (!Email.EMAIL_REGEX.test(value)) {
      throw new Error('Invalid email format');
    }
  }

  equals(other: Email): boolean {
    return this.value === other.value;
  }

  toString(): string {
    return this.value;
  }
}
typescript// ユーザーエンティティ
export class User {
  constructor(
    private readonly id: UserId,
    private readonly email: Email,
    private readonly name: string,
    private readonly createdAt: Date
  ) {}

  // ビジネスルール:ユーザーがアクティブかどうかを判定
  isActive(): boolean {
    const thirtyDaysAgo = new Date();
    thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
    return this.createdAt > thirtyDaysAgo;
  }

  // ビジネスルール:ユーザー名の変更
  changeName(newName: string): User {
    if (!newName || newName.trim().length < 2) {
      throw new Error('Name must be at least 2 characters');
    }

    return new User(
      this.id,
      this.email,
      newName,
      this.createdAt
    );
  }

  // ゲッター
  getId(): UserId {
    return this.id;
  }
  getEmail(): Email {
    return this.email;
  }
  getName(): string {
    return this.name;
  }
  getCreatedAt(): Date {
    return this.createdAt;
  }
}

ドメインサービスの実装

typescript// ユーザー重複チェックのドメインサービス
export class UserDomainService {
  constructor(private userRepository: UserRepository) {}

  async isDuplicateEmail(email: Email): Promise<boolean> {
    const existingUser =
      await this.userRepository.findByEmail(email);
    return existingUser !== null;
  }
}

アプリケーション層(ユースケース)の実装

アプリケーション層では、ユースケースを実装し、ドメイン層とインフラストラクチャ層を調整します。

ユーザー登録ユースケース

typescript// ユーザー登録のリクエストDTO
export interface RegisterUserRequest {
  email: string;
  name: string;
}
typescript// ユーザー登録のレスポンスDTO
export interface RegisterUserResponse {
  id: string;
  email: string;
  name: string;
  createdAt: string;
}
typescript// ユーザー登録ユースケース
export class RegisterUserUseCase {
  constructor(
    private userRepository: UserRepository,
    private emailService: EmailService,
    private userDomainService: UserDomainService
  ) {}

  async execute(
    request: RegisterUserRequest
  ): Promise<RegisterUserResponse> {
    // 入力値の検証
    const email = new Email(request.email);

    if (!request.name || request.name.trim().length < 2) {
      throw new Error('Name must be at least 2 characters');
    }

    // ビジネスルールの適用
    const isDuplicate =
      await this.userDomainService.isDuplicateEmail(email);
    if (isDuplicate) {
      throw new Error(
        'User with this email already exists'
      );
    }

    // エンティティの作成
    const userId = new UserId(this.generateId());
    const user = new User(
      userId,
      email,
      request.name,
      new Date()
    );

    // 永続化
    const savedUser = await this.userRepository.save(user);

    // 外部サービスとの連携
    await this.emailService.sendWelcomeEmail(
      email.toString()
    );

    // レスポンスの構築
    return {
      id: savedUser.getId().toString(),
      email: savedUser.getEmail().toString(),
      name: savedUser.getName(),
      createdAt: savedUser.getCreatedAt().toISOString(),
    };
  }

  private generateId(): string {
    return Math.random().toString(36).substr(2, 9);
  }
}

ユーザー取得ユースケース

typescript// ユーザー取得ユースケース
export class GetUserUseCase {
  constructor(private userRepository: UserRepository) {}

  async execute(
    userId: string
  ): Promise<RegisterUserResponse | null> {
    const id = new UserId(userId);
    const user = await this.userRepository.findById(id);

    if (!user) {
      return null;
    }

    return {
      id: user.getId().toString(),
      email: user.getEmail().toString(),
      name: user.getName(),
      createdAt: user.getCreatedAt().toISOString(),
    };
  }
}

インフラストラクチャ層の実装

インフラストラクチャ層では、外部リソース(データベース、API 等)との具体的な連携を実装します。

リポジトリの実装

typescript// データベーススキーマの型定義
interface UserSchema {
  id: string;
  email: string;
  name: string;
  created_at: Date;
}
typescript// MySQL実装
export class MySQLUserRepository implements UserRepository {
  constructor(private connection: mysql.Connection) {}

  async findById(id: UserId): Promise<User | null> {
    const query = 'SELECT * FROM users WHERE id = ?';
    const [rows] = await this.connection.execute(query, [
      id.toString(),
    ]);

    if (Array.isArray(rows) && rows.length === 0) {
      return null;
    }

    const userData = rows[0] as UserSchema;
    return this.mapToEntity(userData);
  }

  async findByEmail(email: Email): Promise<User | null> {
    const query = 'SELECT * FROM users WHERE email = ?';
    const [rows] = await this.connection.execute(query, [
      email.toString(),
    ]);

    if (Array.isArray(rows) && rows.length === 0) {
      return null;
    }

    const userData = rows[0] as UserSchema;
    return this.mapToEntity(userData);
  }

  async save(user: User): Promise<User> {
    const query = `
      INSERT INTO users (id, email, name, created_at) 
      VALUES (?, ?, ?, ?)
      ON DUPLICATE KEY UPDATE 
        name = VALUES(name)
    `;

    await this.connection.execute(query, [
      user.getId().toString(),
      user.getEmail().toString(),
      user.getName(),
      user.getCreatedAt(),
    ]);

    return user;
  }

  async delete(id: UserId): Promise<void> {
    const query = 'DELETE FROM users WHERE id = ?';
    await this.connection.execute(query, [id.toString()]);
  }

  private mapToEntity(schema: UserSchema): User {
    return new User(
      new UserId(schema.id),
      new Email(schema.email),
      schema.name,
      schema.created_at
    );
  }
}

外部サービスの実装

typescript// メールサービスの実装
export class SendGridEmailService implements EmailService {
  constructor(private apiKey: string) {}

  async sendWelcomeEmail(email: string): Promise<void> {
    const msg = {
      to: email,
      from: 'noreply@example.com',
      subject: 'Welcome to our service!',
      text: 'Thank you for registering with us.',
      html: '<p>Thank you for registering with us.</p>',
    };

    try {
      await sgMail.send(msg);
    } catch (error) {
      console.error('Failed to send welcome email:', error);
      throw new Error('Failed to send welcome email');
    }
  }
}

テスト用の実装

typescript// インメモリ実装(テスト用)
export class InMemoryUserRepository
  implements UserRepository
{
  private users = new Map<string, User>();

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

  async findByEmail(email: Email): Promise<User | null> {
    for (const user of this.users.values()) {
      if (user.getEmail().equals(email)) {
        return user;
      }
    }
    return null;
  }

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

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

プレゼンテーション層の実装

プレゼンテーション層では、HTTP リクエストを受け取り、ユースケースを実行して、レスポンスを返します。

Express.js コントローラーの実装

typescript// コントローラーの基底クラス
export abstract class BaseController {
  protected handleSuccess<T>(
    res: Response,
    data: T,
    statusCode: number = 200
  ): void {
    res.status(statusCode).json({
      success: true,
      data,
    });
  }

  protected handleError(res: Response, error: Error): void {
    console.error('Controller error:', error);

    if (error.message.includes('already exists')) {
      res.status(409).json({
        success: false,
        error: error.message,
      });
      return;
    }

    if (
      error.message.includes('Invalid') ||
      error.message.includes('must be')
    ) {
      res.status(400).json({
        success: false,
        error: error.message,
      });
      return;
    }

    res.status(500).json({
      success: false,
      error: 'Internal server error',
    });
  }
}
typescript// ユーザーコントローラー
export class UserController extends BaseController {
  constructor(
    private registerUserUseCase: RegisterUserUseCase,
    private getUserUseCase: GetUserUseCase
  ) {
    super();
  }

  async register(
    req: Request,
    res: Response
  ): Promise<void> {
    try {
      const { email, name } = req.body;

      const result = await this.registerUserUseCase.execute(
        {
          email,
          name,
        }
      );

      this.handleSuccess(res, result, 201);
    } catch (error) {
      this.handleError(res, error as Error);
    }
  }

  async getById(
    req: Request,
    res: Response
  ): Promise<void> {
    try {
      const { id } = req.params;

      const user = await this.getUserUseCase.execute(id);

      if (!user) {
        res.status(404).json({
          success: false,
          error: 'User not found',
        });
        return;
      }

      this.handleSuccess(res, user);
    } catch (error) {
      this.handleError(res, error as Error);
    }
  }
}

ルートの設定

typescript// ルート設定
export class UserRoutes {
  constructor(private userController: UserController) {}

  configure(router: Router): void {
    router.post(
      '/users',
      this.userController.register.bind(this.userController)
    );
    router.get(
      '/users/:id',
      this.userController.getById.bind(this.userController)
    );
  }
}

依存性注入の設定

typescript// 依存性注入の設定
export class DIContainer {
  private static instance: DIContainer;
  private services = new Map<string, any>();

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

  configure(): void {
    // インフラストラクチャ層
    this.services.set(
      'userRepository',
      () => new MySQLUserRepository(connection)
    );
    this.services.set(
      'emailService',
      () =>
        new SendGridEmailService(
          process.env.SENDGRID_API_KEY!
        )
    );

    // ドメイン層
    this.services.set(
      'userDomainService',
      () =>
        new UserDomainService(
          this.resolve('userRepository')
        )
    );

    // アプリケーション層
    this.services.set(
      'registerUserUseCase',
      () =>
        new RegisterUserUseCase(
          this.resolve('userRepository'),
          this.resolve('emailService'),
          this.resolve('userDomainService')
        )
    );

    this.services.set(
      'getUserUseCase',
      () =>
        new GetUserUseCase(this.resolve('userRepository'))
    );

    // プレゼンテーション層
    this.services.set(
      'userController',
      () =>
        new UserController(
          this.resolve('registerUserUseCase'),
          this.resolve('getUserUseCase')
        )
    );
  }

  resolve<T>(key: string): T {
    const factory = this.services.get(key);
    if (!factory) {
      throw new Error(`Service ${key} not found`);
    }
    return factory();
  }
}

単体テストの例

typescript// ユースケースのテスト
describe('RegisterUserUseCase', () => {
  let useCase: RegisterUserUseCase;
  let userRepository: InMemoryUserRepository;
  let emailService: jest.Mocked<EmailService>;
  let userDomainService: UserDomainService;

  beforeEach(() => {
    userRepository = new InMemoryUserRepository();
    emailService = {
      sendWelcomeEmail: jest
        .fn()
        .mockResolvedValue(undefined),
    };
    userDomainService = new UserDomainService(
      userRepository
    );
    useCase = new RegisterUserUseCase(
      userRepository,
      emailService,
      userDomainService
    );
  });

  it('should register a new user successfully', async () => {
    // Arrange
    const request: RegisterUserRequest = {
      email: 'test@example.com',
      name: 'Test User',
    };

    // Act
    const result = await useCase.execute(request);

    // Assert
    expect(result).toEqual({
      id: expect.any(String),
      email: 'test@example.com',
      name: 'Test User',
      createdAt: expect.any(String),
    });
    expect(
      emailService.sendWelcomeEmail
    ).toHaveBeenCalledWith('test@example.com');
  });

  it('should throw error when email is duplicated', async () => {
    // Arrange
    const email = new Email('test@example.com');
    const existingUser = new User(
      new UserId('existing-id'),
      email,
      'Existing User',
      new Date()
    );
    await userRepository.save(existingUser);

    const request: RegisterUserRequest = {
      email: 'test@example.com',
      name: 'New User',
    };

    // Act & Assert
    await expect(useCase.execute(request)).rejects.toThrow(
      'User with this email already exists'
    );
  });
});

まとめ

TypeScript でクリーンアーキテクチャを実装することで、以下のような大きなメリットを得られます。

まず、保守性の向上です。各層が明確に分離されているため、変更の影響範囲を限定でき、機能追加や修正が容易になります。特に、ビジネスロジックがドメイン層に集約されているため、要求の変更に対してより柔軟に対応できるでしょう。

次に、テスタビリティの改善が挙げられます。依存性注入により、各層を独立してテストできるため、品質の高いソフトウェアを構築できます。モックやスタブを容易に作成でき、テスト駆動開発(TDD)も実践しやすくなります。

また、TypeScript の型安全性を最大限活用できます。インターフェースによる抽象化と組み合わせることで、コンパイル時にエラーを検出でき、実行時エラーを大幅に削減できるでしょう。

最後に、チーム開発での効率性が向上します。層が明確に分離されているため、開発者は自分の担当する層に集中でき、並行開発がスムーズに進められます。新しいメンバーも構造を理解しやすく、学習コストを削減できます。

図で理解できる要点:

  • 依存関係は常に内向きに流れ、外側の層は内側の詳細を知らない
  • 各層は明確な責務を持ち、関心事が適切に分離されている
  • インターフェースによる抽象化により、具体的な実装に依存しない柔軟な設計が実現される

クリーンアーキテクチャは初期の学習コストは高いものの、長期的なプロジェクトの成功において非常に価値のある投資となります。ぜひ実際のプロジェクトで実践してみてください。

関連リンク