📖

TypeScriptで実践する疎結合&拡張性の高いアプリ設計:オニオンアーキテクチャ×Command&Mediatorパターン

2025/01/13に公開

💁 はじめに

アプリケーションを作っていると、機能追加や改善を重ねるたびにどんどん複雑になって、思わぬところでエラーが出たり、修正に時間がかかったりしませんか?特にスタートアップや大規模なシステムでは、スピード感を持って開発しつつ、安定した動作も求められますよね。こうした課題を解決するには、各機能を疎結合にして、柔軟に変更や追加ができるアーキテクチャが欠かせません。この記事では、TypeScriptを使って、Commandパターン、Mediatorパターン、オニオンアーキテクチャを組み合わせた、シンプルでスケーラブルなアプリ設計について解説していきます。

サンプルコードはこちらのGitHubリポジトリを参照してください:ts-command-handler-example
※ 2025/1/19 追記:こちらのサンプルコードには、こちらの記事が反映されています。


🧅 オニオンアーキテクチャとは

オニオンアーキテクチャは、ソフトウェアを層状に構成し、依存関係を明確化してビジネスロジックの独立性を高める設計手法です。

レイヤー構成

  1. ドメイン層: ビジネスルールやエンティティの定義
  2. アプリケーション層: ユースケースやコマンドオブジェクトの定義
  3. インフラストラクチャ層: 外部システム(DB、API)との連携
  4. プレゼンテーション層: UIやAPIエンドポイント

🎮 Commandパターンとは

Commandパターンは、操作(コマンド)とその実行(ハンドラー)を分離するデザインパターンです。このパターンにより、機能の追加や変更が柔軟に行えるようになります。

基本構成

  • Command: 実行する操作を表現するオブジェクト
  • Handler: Commandを受け取って処理を実行する役割

実装例

interface ICommand {
  execute(): void;
}

class AddUserCommand implements ICommand {
  constructor(private username: string) {}
  execute(): void {
    console.log(`User ${this.username} created.`);
  }
}

const AddUserCommand = new AddUserCommand('Alice');
createUserCommand.execute();

ただし、後述のCommandBusを導入することで、executeメソッドを削除し、Commandは実行に必要なパラメータのみを保持するシンプルなオブジェクトに変更します。


📡 MediatorパターンとCommandBusの導入

Mediatorパターンは、オブジェクト同士の直接的な依存関係を排除し、オブジェクト間の通信を仲介役(Mediator)に任せるデザインパターンです。これにより、コンポーネント間の結合度が下がり、システムの柔軟性と保守性が向上します。

CommandBusの役割

CommandBusは、コマンドの発行と処理の仲介を行うMediatorの一種です。これを導入することで、コマンドの実行やハンドラーの呼び出しが統一され、コードの見通しが良くなります。

実装例

interface ICommand {}

interface CommandHandler<T extends ICommand> {
  handle(command: T): void;
}

// CommandBusの実装 (オニオンアーキテクチャにおいてはアプリケーション層)
export class CommandBus {
  private handlers = new Map<string, ICommandHandler<ICommand>>()

  register<T extends ICommand>(
    commandType: new (...args: any[]) => T,
    handler: ICommandHandler<T>,
  ): void {
    this.handlers.set(commandType.name, handler as ICommandHandler<ICommand>)
  }

  async execute<T extends ICommand>(command: T): Promise<unknown> {
    const handler = this.handlers.get(command.constructor.name)
    if (!handler) {
      throw new Error(
        `Handler not found for command: ${command.constructor.name}`,
      )
    }
    return await handler.handle(command)
  }
}

🧩 オニオンアーキテクチャとCommandパターン、CommandBusの統合

オニオンアーキテクチャとCommandパターン、CommandBusを統合することで、より柔軟で保守性の高いアプリケーション設計が可能になります。

統合のメリット

  • 疎結合の実現: 各層の役割が明確になり、依存関係が一方向になる。
  • 拡張性の向上: 新たな機能追加や変更が容易になる。
  • テスト容易性: 各層が独立しているため、ユニットテストが行いやすい。

実装例

以下に、オニオンアーキテクチャとCommandパターン、CommandBusを統合した実装例を示します。

// Userエンティティの定義(ドメイン層)
export class User {
  private constructor(
    readonly id: string,
    name: string,
    email: string,
  ) {}

  static create(name: string, email: string): User {
    return new User(
      randomUUID(),
      props.name,
      props.email,
    )
  }
}
// Commandの定義(アプリケーション層)
interface ICommand {}

export class AddUserCommand implements ICommand {
  constructor(
    public readonly name: string,
    public readonly email: string,
  ) {}
}
// DTOの定義(アプリケーション層)
export class UserDTO {
  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly email: string,
  ) {}

  // ドメインエンティティをDTOに変換
  static fromDomain(user: User): UserDTO {
    return new UserDTO(user.id, user.name, user.email)
  }
}
// Handlerの実装(アプリケーション層)
class AddUserHandler {
  constructor(private userRepository: IUserRepository) {}

  handle(command: AddUserCommand): UserDTO {
    const user: User = User.create(
      command.name,
      command.email,
    })
    this.userRepository.save(user)
    return UserDTO.fromDomain(user)
  }
}
// Repositoryのインターフェース(ドメイン層)
interface IUserRepository {
  save(user: User): void;
}

// Repositoryの実装(インフラ層)
class InMemoryUserRepository implements IUserRepository {
  private users: User[] = []

  save(user: User): void {
    const index = this.users.findIndex((item) => item.id === user.id)
    if (index !== -1) {
      this.users[index] = user // Update
    } else {
      this.users.push(user) // Insert
    }
  }
}
// Controllerの実装(プレゼンテーション層)
class UserController {
  constructor(private commandBus: CommandBus) {}

  async addUser(name: string, email: string): Promise<void> {
    const command = new AddUserCommand({name, email})
    await this.commandBus.execute(command)
  }
}
// Controllerの呼び出し
const userRepository = new InMemoryUserRepository();
const addUserhandler = new AddUserHandler(userRepository);
const commandBus = new CommandBus();
commandBus.register(AddUserCommand, addUserhandler);

const userController = new UserController(commandBus)

userController.addUser('Alice', 'alice@example.com')
userController.addUser('Bob', 'bob@example.com')

🏆 統合のメリット

1. 責務の明確化と分離

オニオンアーキテクチャは、ビジネスロジック(ドメイン層)を中心に据え、外側にアプリケーション層やインフラ層を配置することで、責務の分離を明確にします。
Commandパターンは、各処理をコマンドオブジェクトにカプセル化することで、アクションごとに責務を分割します。
Mediatorパターン(CommandBus)は、コマンドの発行者(Controllerなど)と処理の実行者(Handler)の仲介役を担い、依存関係を排除します。

🔎 結果

各レイヤーの役割が明確になり、コードの可読性と保守性が向上します。
コントローラーやUIは、ビジネスロジックに依存せず、シンプルに保てます。


2. 疎結合と柔軟性

CommandパターンとMediatorパターンの導入により、レイヤー間の依存関係が疎結合になります。
CommandBus(Mediator)がコマンドとハンドラーを仲介することで、直接的な依存関係がなくなります。

🔎 結果

新しい機能の追加・修正が簡単(Handlerの追加や変更だけで対応可能)。
依存関係の変化が最小限になり、システムが柔軟に拡張可能。


3. 拡張性

Command/Handlerの追加が簡単で、機能の追加・拡張が容易になります。
CommandBusを介することで、新しいコマンドや機能が既存コードに影響を与えずに追加できます。

🔎 結果

マイクロサービス化や複雑なビジネスロジックの導入がしやすくなり、システムのスケーラビリティが向上します。

4. 共通処理の一元管理

CommandBusを利用することで、以下のような横断的関心事を一括で処理できます。

  • ログ出力
  • バリデーション
  • トランザクション管理
  • エラーハンドリング

🔎 結果

DRY(Don't Repeat Yourself)原則が守られ、重複コードの削減と保守性の向上が実現します。

5. テスト容易性

Commandパターンにより、ビジネスロジックはコマンド単位で切り出され、テストが簡単になります。
CommandBus経由で呼び出しが行われるため、モック化やスタブ化がしやすくなります。

🔎 結果

ユニットテストが容易になり、ビジネスロジックの検証がしやすくなります。
疎結合のため、モジュール単位のテストが可能です。

6. セキュリティの強化

DTO(Data Transfer Object)を導入し、ドメインエンティティを直接返さず、必要な情報だけを外部に公開します。
オニオンアーキテクチャで、外部層が内部層に一方向依存するため、ビジネスロジックが外部から直接操作されるリスクが低減します。

🔎 結果

  • データ漏洩リスクの軽減
  • ビジネスルールの保護

7. 技術選択の柔軟性

インフラ層(Infrastructure Layer)の実装は、ドメイン層やアプリケーション層に依存しないため、技術の変更や置き換えが容易です。

  • 例:DBの変更(MySQL → MongoDB)、APIの外部連携の追加

🔎 結果

  • 技術スタックの変更・拡張が低コストで可能。
  • マイクロサービスやクラウド化への対応もスムーズ。

📚 統合のイメージ

Presentation Layer (Controller, API)
           ↓
     CommandBus (Mediator)
           ↓
 Application Layer (Commands, Handlers)
           ↓
    Domain Layer (Entities, Services)
           ↓
Infrastructure Layer (DB, API, External Services)

🌟 統合による全体的なメリットまとめ

  • 責務の明確化 → コードの可読性・保守性が向上
  • 疎結合の実現 → 変更や追加に強く、柔軟に拡張可能
  • テスト性向上 → 単体テスト・モジュールテストがしやすい
  • 共通処理の一元化 → ロギング・バリデーションの集中管理
  • セキュリティの向上 → データの安全な管理と公開
  • 技術の柔軟性 → 技術変更や機能追加のコスト削減

🎯 まとめ

CommandパターンとMediatorパターン(CommandBus)をオニオンアーキテクチャと統合することで、疎結合・拡張性・保守性・テスト性・セキュリティのすべてが向上します。
これにより、スケーラブルで柔軟かつ堅牢なシステムの構築が可能になります!🚀

より詳細な実装は、以下のリポジトリをご覧ください:ts-command-handler-example

Discussion