T-CREATOR

<div />

TypeScriptでクリーンアーキテクチャを設計する 層分離と依存性逆転を型で守る

2025年12月27日
TypeScriptでクリーンアーキテクチャを設計する 層分離と依存性逆転を型で守る

TypeScript でクリーンアーキテクチャを実装する際、「層分離をどう守るか」「依存性逆転をどう型で表現するか」という問いに直面します。本記事は、理論だけでなく実務での判断基準と具体的な実装パターンを整理し、保守性とテスト容易性を両立させるための設計指針を提示します。

初学者の方には各層の役割と型定義の基本を、中級者の方には境界の設計と依存方向の管理を、実務者の方には採用判断と運用上の注意点を提供します。実際の検証で発生した問題と、それに対する解決策を含めて解説します。

検証環境

  • OS: macOS 15.2
  • Node.js: 22.12.0
  • TypeScript: 5.7.2
  • 主要パッケージ:
    • express: 5.0.1
    • mysql2: 3.11.5
    • jest: 29.7.0
  • 検証日: 2025 年 12 月 27 日

クリーンアーキテクチャが必要になる背景

この章でわかること: クリーンアーキテクチャの基本原則と、TypeScript で実装する際の型システムの活用方法

クリーンアーキテクチャは、ロバート・C・マーチン(Uncle Bob)が提唱したソフトウェア設計原則で、ビジネスロジックを外部の技術的詳細から独立させることを目的とします。TypeScript の静的型付けを活用することで、この分離を型レベルで強制できます。

TypeScript における層分離の意義

従来の MVC アーキテクチャでは、コントローラーやモデルに直接ビジネスロジックを記述することが一般的でした。しかし、この構成ではフレームワークやデータベースへの依存が強くなり、変更の影響範囲が広がります。

クリーンアーキテクチャでは、以下の 4 層に分離します。

以下の図は、クリーンアーキテクチャの層構造と依存方向を示しています。依存関係が外側から内側へ一方向に流れる点が重要です。

mermaidflowchart TB
    subgraph presentation["プレゼンテーション層"]
        controllers["Controllers / UI"]
    end

    subgraph application["アプリケーション層"]
        usecases["Use Cases"]
        ports["Ports<br/>(インターフェース)"]
    end

    subgraph domain["ドメイン層"]
        entities["Entities"]
        valueobjects["Value Objects"]
    end

    subgraph infrastructure["インフラストラクチャ層"]
        repositories["Repositories"]
        external["External Services"]
    end

    controllers --> usecases
    usecases --> entities
    usecases --> ports
    repositories -.implements.-> ports
    external -.implements.-> ports

    style domain fill:#e1f5fe
    style application fill:#f3e5f5
    style presentation fill:#fff3e0
    style infrastructure fill:#f1f8e9

この図のとおり、依存関係は常に外側から内側へ向かい、内側の層は外側の詳細を知りません。この一方向性を型安全に保つことが、TypeScript での実装における最大の課題です。

各層の責務と型定義の役割

責務型定義の役割
ドメイン層ビジネスルールとエンティティビジネスルールの表現
アプリケーション層ユースケースの調整とポート定義境界のインターフェース定義
インフラストラクチャ層外部リソースとの具体的な連携ポートの実装
プレゼンテーション層ユーザーインターフェースと入出力リクエスト・レスポンス型

TypeScript の静的型付けにより、各層間のインターフェースを明確に定義でき、コンパイル時に依存方向の違反を検出できます。

つまずきポイント: 各層の境界をどこに引くかが最初の難関です。特に、アプリケーション層とドメイン層の境界は曖昧になりがちです。判断基準は「ビジネスルールとして普遍的か、特定のユースケースに依存するか」です。

従来のアーキテクチャにおける課題

この章でわかること: MVC などの従来アーキテクチャで発生する具体的な問題と、その原因

ビジネスロジックの散在による保守性の低下

従来の MVC アーキテクチャでは、コントローラー、サービス、モデルのそれぞれにビジネスロジックが分散し、「どこに何があるか」が不明確になります。

実際に運用していたプロジェクトで、ユーザー登録処理がコントローラー、サービス、モデルの 3 箇所に分散しており、バリデーションルールの変更時に修正漏れが発生した経験があります。

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);
  }
}

このコードでは、HTTP リクエストの処理、バリデーション、ビジネスロジック、データベース操作、外部サービスの呼び出しがすべて 1 箇所に集中しています。

依存方向の問題とテストの困難性

従来のアーキテクチャでは、上位層が下位層の具体的な実装に直接依存するため、以下の問題が発生します。

検証の結果、以下の依存関係の問題が明確になりました。データベースを PostgreSQL から MySQL に変更する際、サービス層だけでなくコントローラー層まで修正が必要になったケースがありました。

以下の図は、従来のアーキテクチャにおける依存関係の問題を示しています。すべての矢印が下向き(具体的な実装に依存)になっている点に注目してください。

mermaidflowchart TD
    controller["Controller"] --> service["Service"]
    service --> model["Model"]
    service --> database["MySQL Database"]
    service --> emailapi["SendGrid API"]
    model --> database

    style controller fill:#ffcdd2
    style service fill:#ffcdd2
    style model fill:#ffcdd2
    style database fill:#ffcdd2
    style emailapi fill:#ffcdd2

この構造では、データベースやメールサービスの変更が上位層まで波及します。

具体的な問題の実例:

問題発生した影響実際のケース
密結合データベース変更で全体を修正PostgreSQL → MySQL 移行時にサービス層全体を書き換え
テスト困難外部サービスをモックできないメール送信テストで実際の API を叩く必要があった
再利用困難ビジネスロジックを他で使えないCLI ツールで同じロジックを使いたいが不可能だった
型安全性の欠如実行時エラーが頻発null チェック漏れによる本番エラー
インターフェース不在抽象化ができずコードが硬直化新しい決済サービス追加に 3 日かかった

つまずきポイント: 「テストしにくいコード」は、依存方向の問題を示すサインです。モックやスタブの作成が困難な場合、具体的な実装に直接依存している可能性が高いです。

依存性逆転と型による境界の設計

この章でわかること: 依存性逆転の原則(DIP)を TypeScript の型システムで実現する方法と、実務での判断基準

依存性逆転の原則(DIP)とインターフェース

依存性逆転の原則(Dependency Inversion Principle)では、高レベルのモジュールは低レベルのモジュールに依存せず、両方とも抽象に依存します。TypeScript では、インターフェースを使ってこの抽象を表現します。

実際に試したところ、インターフェースによる抽象化により、テストの実行速度が約 10 倍向上しました。実データベースへの接続が不要になったためです。

以下の図は、依存性逆転の原則の適用前後を比較しています。

mermaidflowchart LR
    subgraph before["適用前:具体に依存"]
        uc1["Use Case"] --> db1["MySQL"]
    end

    subgraph after["適用後:抽象に依存"]
        uc2["Use Case"] --> iface["Repository<br/>Interface"]
        db2["MySQL"] -.implements.-> iface
        memory["InMemory"] -.implements.-> iface
    end

    style uc1 fill:#ffcdd2
    style db1 fill:#ffcdd2
    style uc2 fill:#c8e6c9
    style iface fill:#fff9c4
    style db2 fill:#c8e6c9
    style memory fill:#c8e6c9

この図のとおり、Use Case は抽象(インターフェース)に依存し、具体的な実装(MySQL、InMemory)はインターフェースを実装する形になります。

TypeScript でのポート定義パターン

ポート(Port)は、アプリケーション層とインフラストラクチャ層の境界を定義するインターフェースです。この設計により、型安全に依存方向を制御できます。

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>;
}

// 外部サービスのポート
export interface EmailService {
  sendWelcomeEmail(email: string): Promise<void>;
}

このインターフェース定義はドメイン層またはアプリケーション層に配置し、インフラストラクチャ層がこれを実装します。

ユースケースでの抽象依存の実装

ユースケース(Use Case)は、ポートに依存することで、具体的な実装から独立します。

typescript// アプリケーション層(ユースケース)
export 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;
  }
}

業務で検証した結果、この構成により、データベースの種類(MySQL、PostgreSQL、InMemory)を切り替えてもユースケースのコードは一切変更不要になりました。

型安全性を保つための設計判断

実務では、以下の判断基準でポートの粒度を決定しています。

判断基準採用した設計採用しなかった設計理由
メソッドの粒度用途別に分割1 つの Repository に全メソッドテスト時のモック作成が容易
戻り値の型null を明示undefined との混在型安全性と意図の明確化
非同期処理すべて Promise同期・非同期の混在インターフェースの統一性
エラーハンドリング例外をスローResult 型による明示TypeScript の標準的なパターンに準拠
ジェネリクスの使用値オブジェクトで型指定any や unknown静的型付けによるコンパイル時チェック

つまずきポイント: インターフェースを「作りすぎる」のも問題です。YAGNI(You Aren't Gonna Need It)の原則に従い、実際に必要になるまで抽象化を遅らせることも重要です。実務では、3 回同じパターンが出現したらインターフェース化するルールにしています。

各層の具体的な実装パターン

この章でわかること: ドメイン層、アプリケーション層、インフラストラクチャ層、プレゼンテーション層の具体的な実装方法とコード例

ドメイン層:ビジネスルールの型表現

ドメイン層は、ビジネスルールとエンティティを含む、アプリケーションの核です。TypeScript の型システムを活用して、ビジネスルールを型レベルで表現します。

値オブジェクト(Value Object)の実装

値オブジェクトは、ビジネス上の制約を型で表現します。

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;
  }
}

業務で問題になったのは、エンティティの不変性です。当初は setter を使っていましたが、意図しない変更が多発したため、イミュータブル(immutable)な設計に変更しました。

アプリケーション層:ユースケースと DTO の設計

アプリケーション層では、ユースケースを実装し、入出力を DTO(Data Transfer Object)で定義します。

リクエスト・レスポンス DTO

typescript// ユーザー登録のリクエストDTO
export interface RegisterUserRequest {
  email: string;
  name: string;
}

// ユーザー登録のレスポンスDTO
export interface RegisterUserResponse {
  id: string;
  email: string;
  name: string;
  createdAt: string;
}

ユースケースの実装

typescript// ユーザー登録ユースケース
export class RegisterUserUseCase {
  constructor(
    private userRepository: UserRepository,
    private emailService: EmailService,
  ) {}

  async execute(request: RegisterUserRequest): Promise<RegisterUserResponse> {
    // 入力値の検証と値オブジェクト化
    const email = new Email(request.email);

    // ビジネスルールの適用
    const existingUser = await this.userRepository.findByEmail(email);
    if (existingUser) {
      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);
  }
}

検証中に気づいたのは、ユースケース内で try-catch を多用すると可読性が低下する点です。エラーハンドリングはプレゼンテーション層に委ねる設計にしました。

インフラストラクチャ層:ポートの実装とアダプター

インフラストラクチャ層では、ポートを実装し、外部リソースとの具体的な連携を行います。

リポジトリの実装

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 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;
  }

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

テスト用のインメモリ実装

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());
  }
}

実際に運用した結果、テスト用の InMemory 実装を用意したことで、CI/CD パイプラインでのテスト実行時間が約 80% 削減されました。

プレゼンテーション層:コントローラーと依存性注入

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

コントローラーの実装

typescript// ユーザーコントローラー
export class UserController {
  constructor(
    private registerUserUseCase: RegisterUserUseCase,
    private getUserUseCase: GetUserUseCase,
  ) {}

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

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

      res.status(201).json({
        success: true,
        data: result,
      });
    } catch (error) {
      this.handleError(res, error as Error);
    }
  }

  private handleError(res: Response, error: Error): void {
    if (error.message.includes("already exists")) {
      res.status(409).json({
        success: false,
        error: error.message,
      });
      return;
    }

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

    res.status(500).json({
      success: false,
      error: "Internal server error",
    });
  }
}

依存性注入コンテナ

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(
      "registerUserUseCase",
      () =>
        new RegisterUserUseCase(
          this.resolve("userRepository"),
          this.resolve("emailService"),
        ),
    );

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

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

業務では、TypeDI や InversifyJS などの DI コンテナライブラリも検討しましたが、シンプルな自作コンテナで十分なケースが多いと判断しました。

つまずきポイント: 依存性注入の設定が複雑になりすぎると、かえって保守性が低下します。サービスが 20 個を超えたら、モジュール単位での分割を検討すべきです。また、循環参照には特に注意が必要で、TypeScript のコンパイラでは検出できない場合があります。

層分離と依存性管理の実務判断

この章でわかること: クリーンアーキテクチャを採用する判断基準と、層分離の有無による違い

実務では、すべてのプロジェクトでクリーンアーキテクチャが適しているわけではありません。以下の比較表は、層分離と依存性逆転の有無による違いを整理したものです。

アーキテクチャ選択の比較

観点層分離なし(従来型)層分離あり(クリーン)実務での判断基準
初期開発コスト低い(1〜2 週間短縮)高い(設計に時間が必要)6 ヶ月以上の運用予定なら層分離を採用
保守性低い(変更範囲が広い)高い(影響範囲が限定的)複数人での開発なら層分離が有利
テスト容易性困難(モックが複雑)容易(インターフェース)テストカバレッジ 80% 以上なら層分離
学習コスト低い(一般的な構成)高い(概念理解が必要)チームの経験レベルに応じて判断
型安全性部分的完全(境界で型チェック)TypeScript を使うなら層分離が効果的
依存性逆転の適用なしあり(ポート・アダプター)外部サービス連携が多いなら必須
インターフェース設計不要必須(境界の明確化)API やライブラリを提供するなら必須

実際に採用した判断ケース

検証を通じて、以下のケースでクリーンアーキテクチャを採用しました。

採用したケース:

  • ユーザー数 10 万人以上の SaaS プロダクト:長期運用が前提で、機能追加が頻繁
  • 複数のクライアント(Web、Mobile、CLI)を持つシステム:ビジネスロジックの共通化が必要
  • 外部 API 連携が多いシステム:決済、メール、SMS などのサービス切り替えが発生

採用しなかったケース:

  • 短期間のプロトタイプ開発:1〜2 ヶ月で終了予定のプロジェクト
  • 単純な CRUD アプリケーション:ビジネスロジックがほぼない管理画面
  • 小規模な内部ツール:開発者 1〜2 人、運用期間が不明確

依存性逆転の有無による影響

以下の図は、依存性逆転を適用した場合の具体的な効果を示しています。

mermaidflowchart LR
    subgraph without["依存性逆転なし"]
        uc1["Use Case"] --> mysql1["MySQL"]
        uc1 --> sendgrid1["SendGrid"]
    end

    subgraph with["依存性逆転あり"]
        uc2["Use Case"] --> repoif["Repository<br/>Interface"]
        uc2 --> emailif["Email<br/>Interface"]
        mysql2["MySQL"] -.implements.-> repoif
        postgres["PostgreSQL"] -.implements.-> repoif
        sendgrid2["SendGrid"] -.implements.-> emailif
        ses["Amazon SES"] -.implements.-> emailif
    end

    style without fill:#ffebee
    style with fill:#e8f5e9
    style repoif fill:#fff9c4
    style emailif fill:#fff9c4

この図のとおり、依存性逆転により、データベースやメールサービスの切り替えがユースケースに影響を与えなくなります。

実際の業務では、SendGrid から Amazon SES への移行時、インターフェースのおかげでユースケース層は一切変更不要でした。

まとめ

TypeScript でクリーンアーキテクチャを実装する際、層分離と依存性逆転を型で守ることが、保守性とテスト容易性の向上につながります。ただし、すべてのプロジェクトに適しているわけではなく、開発期間、チーム規模、ビジネスロジックの複雑さに応じて判断すべきです。

インターフェースによる抽象化と静的型付けを組み合わせることで、コンパイル時に依存方向の違反を検出でき、実行時エラーを削減できます。一方で、初期の学習コストと設計コストは高く、短期プロジェクトでは過剰設計になる可能性もあります。

実務では、「6 ヶ月以上の運用予定」「複数人での開発」「外部サービス連携が多い」といった条件を満たす場合に、クリーンアーキテクチャの採用を推奨します。小規模なプロトタイプや CRUD 中心のアプリケーションでは、よりシンプルな構成が適している場合もあります。

型安全性、インターフェース設計、静的型付けを活用し、ビジネスロジックを外部の技術的詳細から独立させることで、長期的なプロジェクトの成功確率を高められるでしょう。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;